SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%
4.0 KB · 125 lines typescript
Raw Blame History
1import { jaccard, tokenize, type EntityType, type ObservedEntity, type RelationType } from "@src/shared";23/**4 * Social World Model (§23): an in-memory graph of everything observed in this session.5 * Guides navigation (visited set, novelty) and becomes the seed of the persisted graph.6 */7export interface WorldNode {8  fingerprint: string;9  type: EntityType;10  name?: string;11  url?: string;12  platform_id?: string;13  first_seen_step: number;14  last_seen_step: number;15  seen_count: number;16  visited: boolean; // we navigated to it17  surfaces: Set<string>;18  tokens: Set<string>;19}2021export interface WorldEdge {22  from: string;23  to: string;24  type: RelationType;25  step: number;26}2728export class WorldModel {29  readonly nodes = new Map<string, WorldNode>();30  readonly edges: WorldEdge[] = [];31  readonly visitedUrls = new Set<string>();32  private edgeKeys = new Set<string>();3334  /** Register observed entities; returns those never seen before. */35  observe(entities: ObservedEntity[], step: number, discoveredFrom?: string): ObservedEntity[] {36    const fresh: ObservedEntity[] = [];37    for (const e of entities) {38      const n = this.nodes.get(e.fingerprint);39      if (n) {40        n.last_seen_step = step;41        n.seen_count++;42        if (!n.name && e.name) n.name = e.name;43        if (!n.url && e.url) n.url = e.url;44        for (const p of e.provenance) n.surfaces.add(p.surface);45        if (e.name || e.text) n.tokens = tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`);46      } else {47        this.nodes.set(e.fingerprint, {48          fingerprint: e.fingerprint,49          type: e.type,50          name: e.name,51          url: e.url,52          platform_id: e.platform_id,53          first_seen_step: step,54          last_seen_step: step,55          seen_count: 1,56          visited: false,57          surfaces: new Set(e.provenance.map((p) => p.surface)),58          tokens: tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`),59        });60        fresh.push(e);61        if (discoveredFrom) this.relate(discoveredFrom, e.fingerprint, "DISCOVERED_FROM", step);62      }63      if (e.author && e.author_url) {64        // author edge when we know the author's page65        this.relate(`author:${e.author_url}`, e.fingerprint, "AUTHORED", step);66      }67    }68    return fresh;69  }7071  relate(from: string, to: string, type: RelationType, step: number): void {72    const k = `${from}→${to}:${type}`;73    if (this.edgeKeys.has(k)) return;74    this.edgeKeys.add(k);75    this.edges.push({ from, to, type, step });76  }7778  markVisited(fingerprint: string | undefined, url: string | undefined): void {79    if (fingerprint) {80      const n = this.nodes.get(fingerprint);81      if (n) n.visited = true;82    }83    if (url) this.visitedUrls.add(url);84  }8586  isVisited(e: ObservedEntity): boolean {87    return (this.nodes.get(e.fingerprint)?.visited ?? false) || (!!e.url && this.visitedUrls.has(e.url));88  }8990  /** Lexical novelty: 1 − max Jaccard similarity to what we already know (embedding-ready seam, §27). */91  novelty(e: ObservedEntity): number {92    const t = tokenize(`${e.name ?? ""} ${e.text ?? ""} ${e.author ?? ""}`);93    if (t.size === 0) return 0.5;94    let max = 0;95    let checked = 0;96    for (const n of this.nodes.values()) {97      if (n.fingerprint === e.fingerprint) continue;98      if (n.tokens.size === 0) continue;99      const s = jaccard(t, n.tokens);100      if (s > max) max = s;101      if (++checked > 2000) break;102    }103    return 1 - max;104  }105106  stats() {107    const byType: Record<string, number> = {};108    let visited = 0;109    let multi = 0;110    for (const n of this.nodes.values()) {111      byType[n.type] = (byType[n.type] ?? 0) + 1;112      if (n.visited) visited++;113      if (n.surfaces.size > 1) multi++;114    }115    return { nodes: this.nodes.size, edges: this.edges.length, visited, multi_surface: multi, by_type: byType };116  }117118  toJSON() {119    return {120      nodes: [...this.nodes.values()].map((n) => ({ ...n, surfaces: [...n.surfaces], tokens: undefined })),121      edges: this.edges,122    };123  }124}125