/** * Finding helpers — v1 minimum handoff from focused contributions. * * Deterministic (no LLM). Each observation in a focused contribution * yields exactly one provisional Finding. Findings may influence * Current Understanding only and must never touch SituationGraph, * activeUnknownNodeId, selectedQuestion, or frontier selection. */ // ── Fixed disposition values ────────────────────────────── export const FINDING_DISPOSITION_VALUES = ["agree", "not_quite", "not_relevant"]; // ── Deterministic id derivation ─────────────────────────── /** * Derive a stable, collision-resistant Finding id from the source observation * and its originating contribution reference. */ function hashString(str) { let h = 0; for (let i = 0; i < str.length; i++) { h = (Math.imul(31, h) + str.charCodeAt(i)) | 0; } return h; } export function deriveFindingId(sourceObservation, contributionId) { const seed = `${contributionId}|${sourceObservation}`; const hash = hashString(seed); return "finding-" + Math.abs(hash).toString(36).slice(0, 7); } // ── Finding derivation (contribution → findings) ────────── /** * Given an array of contributions that each carry observations[], * produce one provisional Finding per observation. * * Returns: { findings, evaluation } — pure result, no side effects. */ export function deriveFindingsFromContributions(contributions) { const findings = []; let contribIdx = 0; for (const contrib of contributions) { if (!contrib?.observations || !Array.isArray(contrib.observations)) continue; const targetId = contrib.targetNodeId ?? ""; const contribSeq = contrib.sequence != null ? String(contrib.sequence) : String(++contribIdx); const contribId = contrib.id ?? `contrib-${String(contribIdx).padStart(4, "0")}`; for (const obs of contrib.observations) { if (typeof obs !== "string" || !obs.trim()) continue; findings.push({ id: deriveFindingId(obs, contribId), proposition: obs, // Finding proposition = exact observation text status: "provisional", userDisposition: null, // default: silence ≠ agreement originatingTargetNodeId: targetId, contributionId: contribId, sourceObservation: obs, // immutable provenance anchor createdAt: new Date().toISOString(), }); } } return { findings }; } // ── Server-side validation ──────────────────────────────── /** Validate a single incoming finding. Returns null when valid or an error string. */ export function validateSingleFinding(finding) { if (!finding || typeof finding !== "object") return "Finding is not an object"; if (typeof finding.proposition !== "string" || !finding.proposition.trim()) return "Malformed: empty or missing proposition"; if (typeof finding.contributionId !== "string" || !finding.contributionId) return "Malformed: contributionId required"; if (!finding.sourceObservation || typeof finding.sourceObservation !== "string") return "Malformed: sourceObservation required and must be string"; if (finding.userDisposition !== null && FINDING_DISPOSITION_VALUES.indexOf(finding.userDisposition) === -1) return "Malformed: invalid userDisposition"; // No graph mutation fields allowed in findings const forbidden = ["situationGraph", "nodes", "edges", "activeUnknownNodeId", "selectedQuestion"]; for (const key of forbidden) { if (key in finding) return `Malformed: unexpected graph field "${key}"`; } // Must have a traceable contribution reference format if (!finding.contributionId.startsWith("contrib-")) return "Untraceable: contributionId must start with contrib-"; return null; } /** Validate an array of findings; return { valid, rejectedErrors }. */ export function validateFindings(findings) { const results = []; const idSet = new Set(); for (const f of findings) { const err = validateSingleFinding(f); if (err) { results.push({ ...f, evaluation: "rejected", reason: err }); continue; } if (idSet.has(f.id)) { results.push({ ...f, evaluation: "rejected", reason: "Duplicate finding id" }); continue; } idSet.add(f.id); // Normalize disposition to canonical value const disposition = f.userDisposition === null ? null : FINDING_DISPOSITION_VALUES.indexOf(f.userDisposition) !== -1 ? f.userDisposition : null; results.push({ ...f, evaluation: "considered", userDisposition: disposition }); } return { findings: results }; } // ── Deduplicate + map dispositions (client helper) ───────── /** Deduplicate by id and map userDisposition to canonical value. */ export function normalizeFindings(findings) { const seen = new Set(); return findings.filter((f) => { if (seen.has(f.id)) return false; seen.add(f.id); return true; }).map((f) => ({ ...f, userDisposition: f.userDisposition === null ? null : FINDING_DISPOSITION_VALUES.indexOf(f.userDisposition) !== -1 ? f.userDisposition : null, })); } // ── Apply validated findings to Current Understanding ───── /** Return new summary string that appends valid finding propositions. */ export function applyFindingsToSummary(summary, validatedFindings) { let text = summary; const agreeTexts = []; const notQuiteTexts = []; const notRelevantTexts = []; for (const f of validatedFindings) { if (f.evaluation === "rejected") continue; // Map disposition to evaluation state switch (f.userDisposition) { case "agree": f.evaluation = "used"; agreeTexts.push(f.proposition); break; case "not_quite": f.evaluation = "not_used"; notQuiteTexts.push(f.proposition); break; case "not_relevant": f.evaluation = "not_used"; notRelevantTexts.push(f.proposition); break; default: /* null disposition → considered only */ break; } } if (agreeTexts.length === 0 && notQuiteTexts.length === 0 && notRelevantTexts.length === 0) { return summary; // no textual change } const parts = []; if (agreeTexts.length > 0) { parts.push(`Confirmed observation${agreeTexts.length > 1 ? "s" : ""}: ${agreeTexts.join("; ")}`); } if (notQuiteTexts.length > 0) { parts.push(`Partial match${notQuiteTexts.length > 1 ? "es" : ""}: ${notQuiteTexts.join("; ")}`); } if (notRelevantTexts.length > 0) { parts.push(`Noted as not directly relevant: ${notRelevantTexts.join("; ")}`); } return text + " [" + parts.join(" | ") + "]"; } // ── Post-authoritative experimental understanding producer ── /** * Deterministic proof-seam: produce Current Understanding from validated findings * AFTER authoritative graph update is settled. * * Eligibility contract (closed): * null → eligible provisional working interpretation * "agree" → eligible confirmed evidence * other/falsy → excluded (not_relevant, rejected, etc.) * * Output affects ONLY the summary/current understanding string. */ export function produceFindingInformedSummary(originalSummary, appendedFindings) { if (!appendedFindings || appendedFindings.length === 0) { return originalSummary; } const parts = []; for (const f of appendedFindings) { if (f.evaluation === "rejected") continue; // null disposition → eligible (provisional working interpretation) // "agree" disposition → eligible (confirmed evidence) if (f.userDisposition === null || f.userDisposition === "agree") { parts.push(f.proposition); } // not_relevant and not_quite → excluded } if (parts.length === 0) { return originalSummary; // no eligible findings } const evidenceText = `Evidence: [${parts.join("; ")}]`; return originalSummary + " " + evidenceText; }