This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

stigmergic / container / analysis.ts
18 kB 504 lines
1// ─── Strata Analysis Engine ───────────────────────────────────────── 2// Cross-references an island (cluster of linked URLs) against: 3// 1. Carry semantic triples from `carry query --format json` 4// 2. Structured carry records from D1 (entities, citations, reasonings) 5// Follows carry semantics: connections are source → relation → target. 6// If one side is in the island, the other side becomes a new connection. 7// Returns structured analysis + new connection records to be created on PDS. 8 9import { readFileSync, existsSync } from "fs"; 10import { join } from "path"; 11 12const CARRY_PATH = process.env.CARRY_PATH || "/data/carry"; 13 14// ─── Types ────────────────────────────────────────────────────────── 15 16interface VertexInfo { 17 uri: string; 18 title: string; 19 description: string; 20 type: string; 21} 22 23interface EdgeInfo { 24 uri: string; 25 did: string; 26 source: string; 27 target: string; 28 connectionType: string; 29 note: string; 30} 31 32interface CarryEntity { 33 atUri: string; 34 name: string; 35 entityType: string; 36 description?: string; 37 url?: string; // The entity's canonical https:// URL 38} 39 40interface CarryCitation { 41 atUri: string; 42 url: string; 43 title: string; 44 takeaway: string; 45 confidence?: string; 46} 47 48interface CarryReasoning { 49 atUri: string; 50 claim: string; 51 evidence: string; 52 reasoningType?: string; 53 url?: string; // The reasoning's source https:// URL 54} 55 56interface CarryData { 57 entities: CarryEntity[]; 58 citations: CarryCitation[]; 59 reasonings: CarryReasoning[]; 60 connections: CarryConnection[]; 61} 62 63// Structured carry connection — a semantic triple 64interface CarryConnection { 65 sourceName: string; // e.g. "Khayame" 66 targetName: string; // e.g. "Vickers Appreciative Systems" 67 sourceUrl: string; // e.g. "https://link.springer.com/..." 68 targetUrl: string; // e.g. "https://pubadmin.institute/..." 69 relation: string; // e.g. "intellectual_influence" 70 context: string; // e.g. "Khayame draws on Vickers' appreciative systems..." 71} 72 73interface NewConnection { 74 source: string; // island vertex URI 75 target: string; // carry record AT URI 76 connectionType: string; // e.g. "network.cosmik.connection#annotates" 77 note: string; // why this connection exists 78} 79 80interface AnalysisInput { 81 did: string; 82 island: { 83 vertices: VertexInfo[]; 84 edges: EdgeInfo[]; 85 }; 86 carryData?: CarryData; 87} 88 89interface AnalysisResult { 90 themes: string[]; 91 highlights: string[]; 92 tensions: string[]; 93 openQuestions: string[]; 94 synthesis: string; 95 newConnections: NewConnection[]; 96} 97 98// ─── Main analysis function ──────────────────────────────────────── 99 100export async function analyze(input: AnalysisInput): Promise<AnalysisResult> { 101 const { island, carryData } = input; 102 103 // 1. Read carry JSON exports (from `carry query --format json` during sync) 104 const carryEntities = readCarryEntities(); 105 const carryCitations = readCarryCitations(); 106 const carryConnections = readCarryConnections(); 107 const entityUrlIndex = buildEntityUrlIndex(); 108 109 // 2. Build island URL set for fast lookup 110 const islandText = buildIslandText(island); 111 const islandUrls = new Set(island.vertices.map(v => v.uri)); 112 113 // 3. Extract themes 114 const themes = extractThemes(islandText); 115 116 // 4. Match carry data against island — follow carry semantics 117 const newConnections = matchCarryData( 118 island, islandUrls, 119 carryEntities, carryCitations, carryConnections, entityUrlIndex, 120 carryData || { entities: [], citations: [], reasonings: [], connections: [] }, 121 ); 122 123 // 6. Flag hot edges 124 const highlights = findHotEdges(islandText, island, newConnections); 125 126 // 7. Find tensions from carry reasonings 127 const tensions = findTensionsFromReasonings(islandText, carryData?.reasonings || []); 128 129 // Deduplicate newConnections 130 const seen = new Set<string>(); 131 const deduped = newConnections.filter(c => { 132 const key = `${c.source}|${c.target}`; 133 if (seen.has(key)) return false; 134 seen.add(key); 135 return true; 136 }).slice(0, 20); 137 138 // Regenerate synthesis and openQuestions with deduped count 139 const finalQuestions = generateOpenQuestions(islandText, island, highlights, tensions, deduped); 140 const finalSynthesis = synthesize(island, themes, highlights, tensions, finalQuestions, deduped); 141 142 return { themes, highlights, tensions, openQuestions: finalQuestions, synthesis: finalSynthesis, newConnections: deduped }; 143} 144 145// ─── Carry JSON readers ──────────────────────────────────────────── 146// Reads carry data exported by `carry query --format json` during sync. 147 148interface CarryEntityRecord { 149 id: string; 150 name: string; 151 url?: string; 152 type?: string; 153 description?: string; 154} 155 156interface CarryCitationRecord { 157 id: string; 158 title: string; 159 url?: string; 160 takeaway?: string; 161} 162 163interface CarryConnectionRecord { 164 id: string; 165 relation: string; 166 source?: string; 167 target?: string; 168 context?: string; 169} 170 171function readCarryJson<T>(filename: string): T[] { 172 const path = join(CARRY_PATH, filename); 173 try { 174 if (!existsSync(path)) return []; 175 const data = readFileSync(path, "utf-8"); 176 return JSON.parse(data) as T[]; 177 } catch (e: any) { 178 console.error(`[carry] Failed to read ${filename}:`, e.message); 179 return []; 180 } 181} 182 183function readCarryEntities(): CarryEntityRecord[] { 184 return readCarryJson<CarryEntityRecord>("org.latha.entity.json"); 185} 186 187function readCarryCitations(): CarryCitationRecord[] { 188 return readCarryJson<CarryCitationRecord>("org.latha.citation.json"); 189} 190 191function readCarryConnections(): CarryConnectionRecord[] { 192 return readCarryJson<CarryConnectionRecord>("org.latha.connection.json"); 193} 194 195// Build entity name → URL lookup from carry export 196function buildEntityUrlIndex(): Map<string, string> { 197 const index = new Map<string, string>(); 198 for (const e of readCarryEntities()) { 199 const url = e.url?.trim().replace(/^"|"$/g, ""); 200 if (url && url.startsWith("http")) { 201 const name = e.name?.trim().replace(/^"|"$/g, ""); 202 if (name) index.set(name, url); 203 } 204 } 205 return index; 206} 207 208// ─── Build island text corpus ────────────────────────────────────── 209 210function buildIslandText(island: { vertices: VertexInfo[]; edges: EdgeInfo[] }): string { 211 const parts: string[] = []; 212 213 for (const v of island.vertices) { 214 parts.push(v.title); 215 if (v.description) parts.push(v.description); 216 } 217 218 for (const e of island.edges) { 219 if (e.note) parts.push(e.note); 220 } 221 222 return parts.join(" "); 223} 224 225// ─── Core mapper: match carry semantic triples against island ────── 226 227function matchCarryData( 228 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 229 islandUrls: Set<string>, 230 carryEntities: CarryEntityRecord[], 231 _carryCitations: CarryCitationRecord[], 232 carryConnections: CarryConnectionRecord[], 233 entityUrlIndex: Map<string, string>, 234 _d1CarryData: CarryData, 235): NewConnection[] { 236 const connections: NewConnection[] = []; 237 238 // ── 1. Carry connections (semantic triples) ────────────────────── 239 // A carry connection is source → relation → target. 240 // If one side's URL is in the island, the other side becomes a new connection. 241 // If both sides are in the island, it's an existing edge (skip). 242 // If neither side is in the island, it's a nearby carry connection 243 // that could expand the island if linked. 244 for (const conn of carryConnections) { 245 const srcName = conn.source || ""; 246 const tgtName = conn.target || ""; 247 const srcUrl = entityUrlIndex.get(srcName) || ""; 248 const tgtUrl = entityUrlIndex.get(tgtName) || ""; 249 250 if (!srcUrl && !tgtUrl) continue; // Neither side has a URL — skip 251 252 const srcInIsland = srcUrl && islandUrls.has(srcUrl); 253 const tgtInIsland = tgtUrl && islandUrls.has(tgtUrl); 254 255 // Both in island → already connected, skip 256 if (srcInIsland && tgtInIsland) continue; 257 258 // One side in island → bridge connection 259 if (srcInIsland && tgtUrl) { 260 connections.push({ 261 source: srcUrl, 262 target: tgtUrl, 263 connectionType: `network.cosmik.connection#${conn.relation}`, 264 note: conn.context || `${srcName}${conn.relation}${tgtName}`, 265 }); 266 } else if (tgtInIsland && srcUrl) { 267 connections.push({ 268 source: tgtUrl, 269 target: srcUrl, 270 connectionType: `network.cosmik.connection#${conn.relation}`, 271 note: conn.context || `${srcName}${conn.relation}${tgtName}`, 272 }); 273 } else if (srcUrl && tgtUrl) { 274 // Neither side in island — emit as a nearby carry connection 275 // (both sides have URLs, so it's a real edge that could expand the island) 276 connections.push({ 277 source: srcUrl, 278 target: tgtUrl, 279 connectionType: `network.cosmik.connection#${conn.relation}`, 280 note: conn.context || `${srcName}${conn.relation}${tgtName}`, 281 }); 282 } 283 // One side has URL but the other doesn't — can't create a full connection 284 } 285 286 // ── 2. Carry entities with URLs not in island ─────────────────── 287 // These are entities that the user tracks but aren't yet in the island. 288 // We don't try to match them by keyword — we just list them as 289 // "nearby entities" for the synthesis to reference. 290 // (No NewConnection created — entities without a connection triple 291 // don't have a semantic relation to the island.) 292 293 return connections; 294} 295 296// ─── Analysis functions ───────────────────────────────────────────── 297 298function extractThemes(text: string): string[] { 299 const keywords: Record<string, string[]> = { 300 "open science": ["open science", "open access", "oa", "preprint"], 301 "data sharing": ["data sharing", "shared data", "open data", "dataset"], 302 "reproducibility": ["reproducib", "replicab", "replication"], 303 "decentralization": ["decentraliz", "federat", "self-host"], 304 "knowledge graphs": ["knowledge graph", "ontolog", "linked data", "semantic"], 305 "ai/ml": ["machine learning", "deep learning", "neural", "llm", "gpt", "claude"], 306 "community": ["community", "collective", "cooperative", "commons"], 307 "governance": ["governance", "policy", "regulation", "standards"], 308 "infrastructure": ["infrastructure", "platform", "tooling", "pipeline"], 309 "stigmergy": ["stigmerg", "pheromone", "emergent", "self-organiz"], 310 "provenance": ["provenance", "lineage", "traceability", "attribution"], 311 "doi": ["doi", "zenodo", "citeable", "citation"], 312 "privacy": ["privacy", "encryption", "e2ee", "consent"], 313 "sovereignty": ["sovereignty", "self-determin", "autonomy", "agency"], 314 "content authenticity": ["content authenticity", "c2pa", "provenance watermark", "deepfake"], 315 "at protocol": ["atproto", "at protocol", "bluesky", "pds", "appview"], 316 "semantic web": ["semantic web", "rdf", "sparql", "linked data"], 317 }; 318 319 const lower = text.toLowerCase(); 320 return Object.entries(keywords) 321 .filter(([_, terms]) => terms.some(t => lower.includes(t))) 322 .map(([theme]) => theme); 323} 324 325function findHotEdges( 326 islandText: string, 327 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 328 newConnections: NewConnection[], 329): string[] { 330 const highlights: string[] = []; 331 332 // Score each edge by semantic significance 333 const edgeScores = new Map<string, number>(); 334 335 for (const edge of island.edges) { 336 let score = 0; 337 338 // Edges with notes are more interesting 339 if (edge.note && edge.note.length > 10) score += 1; 340 341 // Edges with semantic connection types are more interesting 342 const semanticTypes = ["supports", "contradicts", "extends", "contextualizes", "exemplifies"]; 343 if (semanticTypes.some(t => edge.connectionType.toLowerCase().includes(t))) score += 2; 344 345 // Boost edges whose source or target is also connected to carry data 346 for (const nc of newConnections) { 347 if (edge.source === nc.source || edge.target === nc.source) score += 3; 348 if (edge.source === nc.target || edge.target === nc.target) score += 3; 349 } 350 351 if (score > 0) { 352 edgeScores.set(edge.uri, score); 353 } 354 } 355 356 // Return top edges by score 357 const sorted = [...edgeScores.entries()].sort((a, b) => b[1] - a[1]); 358 for (const [uri] of sorted.slice(0, 10)) { 359 highlights.push(uri); 360 } 361 362 return highlights; 363} 364 365function findTensionsFromReasonings( 366 islandText: string, 367 reasonings: CarryReasoning[], 368): string[] { 369 const tensions: string[] = []; 370 const lowerSet = tokenize(islandText); 371 372 for (const r of reasonings) { 373 const rLower = `${r.claim} ${r.evidence}`.toLowerCase(); 374 if (rLower.includes("contradict") || rLower.includes("however") || rLower.includes("but") || rLower.includes("tension")) { 375 const overlap = keywordOverlap(lowerSet, rLower); 376 if (overlap.length >= 1) { 377 tensions.push(r.claim.slice(0, 500)); 378 } 379 } 380 } 381 382 return tensions.slice(0, 5); 383} 384 385function generateOpenQuestions( 386 islandText: string, 387 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 388 highlights: string[], 389 tensions: string[], 390 newConnections: NewConnection[], 391): string[] { 392 const questions: string[] = []; 393 const lower = islandText.toLowerCase(); 394 395 if (newConnections.length > 0) { 396 questions.push( 397 `${newConnections.length} carry touchpoints found. How does your existing knowledge reshape this island?` 398 ); 399 } 400 401 if (tensions.length > 0) { 402 questions.push( 403 `What evidence would resolve the ${tensions.length} tension(s) identified?` 404 ); 405 } 406 407 const vertexCount = island.vertices.length; 408 const edgeTypes = new Set(island.edges.map(e => e.connectionType)); 409 410 if (edgeTypes.size > 1) { 411 questions.push( 412 `This island has ${edgeTypes.size} connection types. What's the dominant relationship?` 413 ); 414 } 415 416 if (vertexCount > 5) { 417 questions.push( 418 `This is a ${vertexCount}-vertex cluster. Are there sub-clusters within it?` 419 ); 420 } 421 422 if (lower.includes("data") || lower.includes("dataset")) { 423 questions.push("What data artifacts does this cluster produce, and are they in Zenodo?"); 424 } 425 if (lower.includes("standard") || lower.includes("protocol")) { 426 questions.push("Does this cluster propose a new standard, and who are the adopters?"); 427 } 428 429 return questions.slice(0, 5); 430} 431 432function synthesize( 433 island: { vertices: VertexInfo[]; edges: EdgeInfo[] }, 434 themes: string[], 435 highlights: string[], 436 tensions: string[], 437 openQuestions: string[], 438 newConnections: NewConnection[], 439): string { 440 const vertexCount = island.vertices.length; 441 const edgeCount = island.edges.length; 442 const edgeTypes = new Set(island.edges.map(e => e.connectionType)); 443 444 const clusterDesc = `${vertexCount} vertices, ${edgeCount} edges, ${edgeTypes.size} connection types`; 445 446 const themeStr = themes.length > 0 447 ? `Themes: ${themes.join(", ")}.` 448 : ""; 449 450 const touchpointStr = newConnections.length > 0 451 ? `${newConnections.length} carry touchpoints — new edges connecting this island to your knowledge base.` 452 : "No carry touchpoints found."; 453 454 const highlightStr = highlights.length > 0 455 ? `${highlights.length} existing edges flagged as semantically significant.` 456 : ""; 457 458 const tensionStr = tensions.length > 0 459 ? `There ${tensions.length === 1 ? "is 1 tension" : `are ${tensions.length} tensions`} with your existing claims.` 460 : ""; 461 462 const questionStr = openQuestions.length > 0 463 ? `Open questions: ${openQuestions.join(" ")}` 464 : ""; 465 466 return [`Island (${clusterDesc}).`, themeStr, touchpointStr, highlightStr, tensionStr, questionStr].filter(Boolean).join(" "); 467} 468 469// ─── Utilities ────────────────────────────────────────────────────── 470 471const STOP_WORDS = new Set([ 472 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", 473 "have", "has", "had", "do", "does", "did", "will", "would", "could", 474 "should", "may", "might", "shall", "can", "to", "of", "in", "for", 475 "on", "with", "at", "by", "from", "as", "into", "through", "during", 476 "before", "after", "above", "below", "between", "and", "but", "or", 477 "not", "no", "nor", "so", "if", "then", "that", "this", "it", "its", 478 "http", "https", "www", "com", "org", 479 // Common vault noise terms 480 "checkin", "check", "daily", "today", "yesterday", "tomorrow", 481 "morning", "evening", "week", "month", "year", 482 "also", "just", "like", "really", "very", "much", "some", 483 "more", "most", "other", "about", "which", "when", "what", 484 "there", "their", "these", "those", "each", "every", "all", 485 "make", "made", "thing", "things", "something", "anything", 486 "need", "want", "know", "think", "going", "come", "get", 487 "good", "great", "nice", "well", "still", "even", "back", 488 "first", "last", "new", "old", "own", "same", "another", 489 "many", "such", "only", "over", "than", "too", "very", 490 "because", "since", "while", "where", "how", "why", "who", 491 "work", "working", "time", "people", "using", "used", "use", 492]); 493 494function tokenize(text: string): Set<string> { 495 return new Set( 496 text.toLowerCase().split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)) 497 ); 498} 499 500 501function keywordOverlap(setA: Set<string>, textB: string): string[] { 502 const wordsB = textB.toLowerCase().split(/\W+/).filter(w => w.length > 3 && !STOP_WORDS.has(w)); 503 return wordsB.filter(w => setA.has(w)); 504}