Feature/product platform foundation v0.62 #1

Merged
robbond merged 683 commits from feature/product-platform-foundation-v0.62 into feature/emergent-unknowns-v0.5 2026-09-09 07:58:20 +01:00
3 changed files with 208 additions and 40 deletions
Showing only changes of commit 0f4dfcbb17 - Show all commits
+70 -40
View File
@@ -138,6 +138,7 @@ function FocusedQuestionBody({
setFollowUpQuestion,
focusedContributions,
currentFindings,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
@@ -195,7 +196,7 @@ function FocusedQuestionBody({
{focused?.result && (
<>
{/* Prior accumulated learning (prior turns, current turn excluded — shown above) */}
<PriorContributionsSummary nodeId={nodeId} contributions={focusedContributions || []} />
<PriorContributionsSummary nodeId={nodeId} contributions={focusedContributions || []} findings={findings} />
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h3><ul className="list-disc pl-5 space-y-2">{(currentFindings?.length ? currentFindings : (focused.result.observations || [])).map((item, i) => {
const isFinding = typeof item === "object" && item !== null && "id" in item;
@@ -468,9 +469,25 @@ function EvidenceLimitCard({ summary }) {
);
}
// ── Canonical findings resolver for Previous Learning ──────────────
function getHistoricalPropositions(contribution, findings) {
const matching = (findings || []).filter(
(f) => f.contributionId === contribution.id,
);
if (matching.length === 0) {
return contribution.observations || [];
}
return matching
.filter((f) => f.userDisposition !== "not_relevant")
.map((f) => f.proposition);
}
// ── Prior contribution summary (embedded within FocusedQuestionBody) ───
function PriorContributionsSummary({ nodeId, contributions }) {
function PriorContributionsSummary({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
@@ -488,19 +505,22 @@ function PriorContributionsSummary({ nodeId, contributions }) {
{priorContribs.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({c.observations?.length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
Turn {c.sequence || idx + 1} contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
</summary>
<div className="pt-2 space-y-3">
{c.observations?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{c.observations.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null}
{(() => {
const propositions = getHistoricalPropositions(c, findings);
return propositions.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{propositions.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null;
})()}
{c.uncertainties?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h5>
@@ -520,7 +540,7 @@ function PriorContributionsSummary({ nodeId, contributions }) {
// ── Standalone previous learning block (for two-column secondary placement) ───
function SecondaryPreviousLearning({ nodeId, contributions }) {
function SecondaryPreviousLearning({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
@@ -538,19 +558,22 @@ function SecondaryPreviousLearning({ nodeId, contributions }) {
{priorContribs.map((c, idx) => (
<details key={c.id || idx} className="mb-2 border-b border-gray-200/40 last:border-0 pb-2 last:pb-0" open={idx === 0}>
<summary className="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 select-none py-1">
Turn {c.sequence || idx + 1} contribution ({c.observations?.length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
Turn {c.sequence || idx + 1} contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
</summary>
<div className="pt-2 space-y-3">
{c.observations?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{c.observations.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null}
{(() => {
const propositions = getHistoricalPropositions(c, findings);
return propositions.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h5>
<ul className="list-disc pl-5 space-y-0.5">
{propositions.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null;
})()}
{c.uncertainties?.length ? (
<div>
<h5 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h5>
@@ -570,7 +593,7 @@ function SecondaryPreviousLearning({ nodeId, contributions }) {
// ── Thread contributions badge (standalone — used outside focused body) ───
function ThreadContributionsBadge({ nodeId, contributions }) {
function ThreadContributionsBadge({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
@@ -601,17 +624,20 @@ function ThreadContributionsBadge({ nodeId, contributions }) {
{threadContribs.map((c, idx) => (
<div key={c.id || idx} className="space-y-2">
{idx > 0 && <div className="text-[9px] text-gray-400 tracking-wider uppercase mt-3">Contribution #{c.sequence || idx + 1}</div>}
{/* What this tells us */}
{c.observations?.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h4>
<ul className="list-disc pl-5 space-y-0.5">
{c.observations.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null}
{/* What this tells us — via canonical Findings */}
{(() => {
const propositions = getHistoricalPropositions(c, findings);
return propositions.length ? (
<div>
<h4 className="text-[10px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h4>
<ul className="list-disc pl-5 space-y-0.5">
{propositions.map((o, i) => (
<li key={i} className="text-xs leading-relaxed text-gray-700">{o}</li>
))}
</ul>
</div>
) : null;
})()}
{/* Still unclear */}
{c.uncertainties?.length ? (
@@ -1070,6 +1096,7 @@ function FocusedInvestigationWorkspace({
hasCompletedInvestigation,
focusedContributions,
currentFindings,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
@@ -1107,7 +1134,7 @@ function FocusedInvestigationWorkspace({
{/* ── Secondary context: Previous Learning — visible on all breakpoints, placed in grid column on wide / flows below primary on narrow ── */}
{hasResult && (
<SecondaryPreviousLearning nodeId={nodeId} contributions={focusedContributions || []} />
<SecondaryPreviousLearning nodeId={nodeId} contributions={focusedContributions || []} findings={findings} />
)}
</div>
</div>
@@ -1122,6 +1149,7 @@ function OpenQuestionsPanel({
startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId,
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
@@ -1206,7 +1234,7 @@ function OpenQuestionsPanel({
/>
{/* Thread contributions for this node */}
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} />
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} findings={findings} />
</div>
</div>
);
@@ -1220,7 +1248,7 @@ function OpenQuestionsPanel({
{graph.nodes.filter((n) => doneForNowIds.includes(n.id)).map((node) => (
<div key={node.id} className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-4 py-3 space-y-1">
<p className="text-sm text-gray-500 leading-snug">{node.label}</p>
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} />
<ThreadContributionsBadge nodeId={node.id} contributions={focusedContributions || []} findings={findings} />
<button onClick={() => setDoneForNowIds(doneForNowIds.filter(id => id !== node.id))} style={{ cursor: "pointer" }} className="mt-1 rounded border border-gray-300 px-3 py-1 text-xs font-medium text-gray-500 hover:bg-white transition">Reopen</button>
</div>
))}
@@ -1917,6 +1945,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
focusedInvestigations={focusedInvestigations}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
findings={findings}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
)}
@@ -2092,6 +2121,7 @@ export default function ReasoningWorkspace({
}}
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
findings={findings}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
+32
View File
@@ -1035,6 +1035,38 @@ The remaining boundaries are NOT persistence issues. They belong to the next fea
**No changes to:** Finding eligibility, Finding disposition, Current Understanding, Done-for-now promotion, SituationGraph, persistence provider, or graph reasoning.
### v0.49.5 — CANONICAL PREVIOUS LEARNING PROPOSITION REPAIR (2026-08-29)
**Objective:** Ensure Previous Learning surfaces on focused investigation cards resolve canonical historical propositions through the Finding→Contribution identity chain rather than showing stale or missing content.
**Resolver semantics implemented in `reasoning-workspace.jsx`:**
| Matching canonical Findings for a Contribution | Behaviour |
|---|---|
| ZERO matching canonical Findings | Fallback to `Contribution.observations` |
| ONE OR MORE matching canonical Findings | Canonical Findings are authoritative; use `Finding.proposition` |
| Matching Findings exist but all are `not_relevant` | Render nothing for that Contribution; DO NOT resurrect old observations |
| Each Contribution's findings filtered by | `Finding.contributionId === Contribution.id` (one-to-one ownership) |
**Applied to historical-learning surfaces:** `PriorContributionsSummary`, `SecondaryPreviousLearning`, `ThreadContributionsBadge`.
**Files changed:**
- `components/reasoning-workspace.jsx` — canonical proposition resolver in Previous Learning panel
- `tests/open-questions-vs-assumptions.test.jsx` — 78 tests covering legacy fallback, empty fallback, corrected canonical proposition, all-not_relevant suppression, turn ownership, mixed dispositions, null/undefined findings
**Deterministic gate:** 78 tests passed. **Build gate:** clean production build.
**Live verification (canonical onboarding fixture):**
- Persisted Finding `finding-5rf99h` carries corrected proposition `"Approximately 62% of users abandoning the verification step report no problem receiving their code. [previous-learning-check]"` with `contributionId: "contrib-0002"`
- Workspace opened without crash or ReferenceError
- Turn 2 → canonical proposition with `[previous-learning-check]` marker rendered in Previous Learning "What this tells us"
- No stale original wording shown; no duplication of old+corrected text
- Correct turn ownership confirmed (Turn 2 = `contrib-0002`, matching Finding scoped to that contribution)
- Zero LLM/API calls during verification
- Persistence/schema/reasoning paths unchanged
**No changes to:** Persistence schema, Finding schema, Contribution schema, SituationGraph reasoning, activity visibility, completed-turn lifecycle, or overlay controls.
---
## RESTORE / PRESENTATION FINDINGS — MANUAL USER-PATH (2026-08-28)
@@ -1140,3 +1140,109 @@ describe("Reopened Finding resolution via Contribution.id → contributionId", (
// Turn 1 Finding NOT merged into latest view.
});
});
// ── getHistoricalPropositions: Previous Learning renders canonical Findings ─
function getHistoricalPropositions(contribution, findings) {
const matching = (findings || []).filter(
(f) => f.contributionId === contribution.id,
);
if (matching.length === 0) {
return contribution.observations || [];
}
return matching
.filter((f) => f.userDisposition !== "not_relevant")
.map((f) => f.proposition);
}
describe("getHistoricalPropositions: Previous Learning uses canonical Findings", () => {
it("zero matching canonical Findings → fallback to Contribution.observations", () => {
const contrib = {
id: "contrib-01",
observations: ["Original observation from contribution"],
};
const findings = [];
const result = getHistoricalPropositions(contrib, findings);
expect(result).toEqual(["Original observation from contribution"]);
});
it("zero matching canonical Findings with null observations → empty array", () => {
const contrib = { id: "contrib-02" };
const findings = [];
const result = getHistoricalPropositions(contrib, findings);
expect(result).toEqual([]);
});
it("one or more matching canonical Findings → canonical Findings are authoritative", () => {
const contrib = { id: "contrib-03", observations: ["Original obs"] };
const findings = [
{ contributionId: "contrib-03", proposition: "Canonical finding A", userDisposition: "agreed" },
{ contributionId: "contrib-03", proposition: "Canonical finding B", userDisposition: "agreed" },
];
const result = getHistoricalPropositions(contrib, findings);
expect(result).toEqual(["Canonical finding A", "Canonical finding B"]);
expect(result).not.toContain("Original obs");
});
it("matching canonical Findings all have userDisposition not_relevant → render NO propositions, DO NOT fall back", () => {
const contrib = { id: "contrib-04", observations: ["Should not appear"] };
const findings = [
{ contributionId: "contrib-04", proposition: "Dismissed finding 1", userDisposition: "not_relevant" },
{ contributionId: "contrib-04", proposition: "Dismissed finding 2", userDisposition: "not_relevant" },
];
const result = getHistoricalPropositions(contrib, findings);
expect(result).toEqual([]);
expect(result).not.toContain("Should not appear");
});
it("turn ownership is strict — only Findings whose contributionId matches the Contribution.id render", () => {
const contribA = { id: "contrib-A", observations: ["A's original"] };
const contribB = { id: "contrib-B", observations: ["B's original"] };
const contribBFinding = { contributionId: "contrib-B", proposition: "Finding from B", userDisposition: "agreed" };
const contribAFinding = { contributionId: "contrib-A", proposition: "Finding from A", userDisposition: "agreed" };
const resultForB = getHistoricalPropositions(contribB, [contribBFinding]);
expect(resultForB).toEqual(["Finding from B"]);
const resultMixed = getHistoricalPropositions(contribB, [contribAFinding, contribBFinding]);
expect(resultMixed).toEqual(["Finding from B"]);
expect(resultMixed).not.toContain("Finding from A");
});
it("mixed dispositions: non-not_relevant Findings render, not_relevant are excluded", () => {
const contrib = { id: "contrib-05", observations: ["Original"] };
const findings = [
{ contributionId: "contrib-05", proposition: "Kept finding", userDisposition: "agreed" },
{ contributionId: "contrib-05", proposition: "Dismissed finding", userDisposition: "not_relevant" },
{ contributionId: "contrib-05", proposition: "Neutral finding", userDisposition: "neutral" },
];
const result = getHistoricalPropositions(contrib, findings);
expect(result).toEqual(["Kept finding", "Neutral finding"]);
expect(result).not.toContain("Dismissed finding");
});
it("null/undefined findings array handled safely — falls back to observations", () => {
const contrib = { id: "contrib-06", observations: ["Fallback safe"] };
expect(getHistoricalPropositions(contrib, null)).toEqual(["Fallback safe"]);
expect(getHistoricalPropositions(contrib, undefined)).toEqual(["Fallback safe"]);
});
it("Contribution with no observations and no matching Findings returns empty", () => {
const contrib = { id: "contrib-07" };
const findings = [
{ contributionId: "contrib-other", proposition: "Wrong contribution", userDisposition: "agreed" },
];
const result = getHistoricalPropositions(contrib, findings);
expect(result).toEqual([]);
});
});