diff --git a/components/diagnostics-view.jsx b/components/diagnostics-view.jsx index 8049c26..352795c 100644 --- a/components/diagnostics-view.jsx +++ b/components/diagnostics-view.jsx @@ -80,6 +80,14 @@ export default function DiagnosticsView({ result }) { ? `${validationIcons.valid} valid` : `${validationIcons.invalid} invalid`, }, + { + label: "Investigation strategy", + value: + diagnostics.investigationStrategy?.key || + diagnostics.investigationStrategy || + result.selectedQuestion?.strategy || + "?", + }, ]; const errors = [ diff --git a/components/graph-update-view.jsx b/components/graph-update-view.jsx index 23a0109..b0a959a 100644 --- a/components/graph-update-view.jsx +++ b/components/graph-update-view.jsx @@ -28,6 +28,8 @@ export default function GraphUpdateView({ updateResult }) { proposal, previousSituationGraph, updatedSituationGraph, + reasoningState, + previousReasoningState, } = updateResult; const newlySurfacedUnknownNodeIds = (proposal.addedNodes || []) @@ -64,6 +66,11 @@ export default function GraphUpdateView({ updateResult }) {
{node.description}
)} + {node.confidenceAssessment && ( ++ evidence: {node.confidenceAssessment.evidenceConfidence} · + conclusion: {node.confidenceAssessment.conclusionConfidence} +
+ )} ))} diff --git a/docs/v0.6-ambiguity-generalisation.md b/docs/v0.6-ambiguity-generalisation.md new file mode 100644 index 0000000..6ae698e --- /dev/null +++ b/docs/v0.6-ambiguity-generalisation.md @@ -0,0 +1,40 @@ +# v0.6 Ambiguity Generalisation + +## Hypothesis + +If the selector truly handles unjustified contradiction ties generically, it should return ambiguity across multiple domains without preferring one explanation by wording alone. + +## Scenarios + +1. Revenue increased by 18%, but cash in the bank fell over the same period. +2. Customer satisfaction scores increased, but complaints also increased. +3. Average delivery time decreased by 25%, but order cancellations increased. +4. Website traffic doubled, but sales remained unchanged. +5. Production output increased by 30%, but quality defects also increased. + +## Observed behaviour + +All five fixtures produced the same pattern: + +- candidate count: 2 +- selector status: `ambiguous` +- tie reason: `No justified distinction between leading unknowns.` +- no explanation was favoured +- one broad investigation question was produced from the central contradiction +- neutral label renaming did not collapse ambiguity into a winner + +## Repeated failure patterns + +None observed across two or more scenarios. + +The current ambiguity handling generalised cleanly across the five contradiction fixtures. + +## Corrections + +No production correction was required in this task. + +## Lessons learned + +- The current ambiguity path appears domain-agnostic when structure and semantic weights remain intentionally non-discriminating. +- Central-statement-based tie questions are broad enough to avoid prematurely backing one branch. +- The most useful regression signal is whether ambiguity survives neutral relabelling, not whether one label sorts ahead of another in display order. diff --git a/docs/v0.6-atomicity-experiment.md b/docs/v0.6-atomicity-experiment.md new file mode 100644 index 0000000..c945112 --- /dev/null +++ b/docs/v0.6-atomicity-experiment.md @@ -0,0 +1,211 @@ +# v0.6 Atomicity Experiment + +## Hypothesis + +After deterministic unknown selection, the engine should assess whether the selected unknown is already atomic or is still too composite to ask directly. + +If the unknown is atomic, the engine should proceed exactly as before. + +If the unknown is composite, the engine should not ask that parent unknown directly. Instead, it should decompose it into a small set of explicit child unknowns representing broad, independent candidate dimensions that a non-expert could understand. + +## Constraints + +- No graph redesign +- No persistence +- No UI redesign +- No selection-weight tuning +- No Ollama calls in unit tests + +## Deterministic rule introduced + +Atomicity assessment is **not** a new investigation strategy. + +It runs in the graph update path at this seam: + +```text +unknown selection -> atomicity assessment -> optional decomposition -> deterministic reselection -> question formulation +``` + +The implementation uses deterministic text and graph-shape checks: + +- focused unknowns like denominator / threshold / definition / baseline / evidence remain **atomic** +- broad relationship-explanation unknowns and broad “possible causes / what changed / explanation for why X but Y” unknowns become **composite** + +## Decomposition behavior + +When a selected unknown is composite: + +1. The parent unknown remains unresolved. +2. Between 2 and 5 child unknowns are created or reused deterministically. +3. Children become explicit graph nodes. +4. Children link back to the parent with existing `depends_on` edges. +5. Children inherit the same “why it matters” discipline in their descriptions. +6. Deterministic selection reruns across the updated graph. + +For the current relationship-explanation experiment, the broad child dimensions are: + +- Whether the two observations reflect different timing +- How the two observations were measured +- Change affecting signal A more than signal B +- Change affecting signal B more than signal A +- One-off event during the period + +These are intentionally non-jargon and broad enough to generalise across scenarios like: + +- Revenue up / Cash down +- Customer satisfaction up / Complaints up +- Delivery time down / Cancellations up +- Traffic up / Sales flat +- Production up / Defects up + +## Diagnostics added + +The orchestrator now reports: + +- `atomicityAssessment` +- `atomicityDecisionReason` +- `decompositionDepth` +- `decompositionAttempted` +- `decompositionAccepted` +- `decompositionStoppedReason` +- `proposedChildCount` +- `acceptedChildCount` +- `rejectedChildren` +- `selectedChildNodeId` +- `childQualitySummary` +- `propagationPerformed` +- `resolvedChildNodeId` +- `parentNodeId` +- `parentStatusBefore` +- `parentStatusAfter` +- `parentConfidenceBefore` +- `parentConfidenceAfter` +- `affectedAncestorIds` +- `nextSelectedSibling` +- `parentResolved` +- `decompositionPerformed` +- `childUnknownCount` +- `childNodeIds` +- `atomicityReason` + +This sits alongside the existing explicit-emergent-unknown diagnostics. + +## Observed outcome + +The experiment was useful. + +Before this change, the engine could select a broad explanation unknown and ask it directly. + +After this change: + +- the broad explanation parent remains explicit in the graph +- the engine decomposes it into child unknowns first +- the next asked question is backed by a more focused child unknown +- repeated updates reuse the same decomposition children deterministically +- child-quality checks reject compound or duplicate children before they enter the graph +- decomposition stops deterministically once a selected child is directly answerable +- resolving one child does not resolve the parent immediately +- resolved child evidence now propagates upward to the parent and ancestor chain deterministically +- parent status and confidence change conservatively after child resolution +- the next sibling becomes eligible for normal deterministic selection without recreating the resolved child + +In the revenue-versus-cash case, the selected next question becomes: + +> What evidence would clarify how the two observations were measured? + +rather than asking the full broad explanation node directly. + +## Upward propagation and reconstruction + +Recursive reasoning is complete only when decomposition and reconstruction are both deterministic. + +Confidence must not outrun completeness or evidence. + +For this experiment, reconstruction now behaves as follows: + +- when a child unknown resolves, that child keeps its own resolved status and answer evidence +- the parent is updated, but remains unresolved unless the deterministic completion rule is satisfied +- only the ancestor chain connected to that child is updated +- unrelated branches remain unchanged +- the deterministic selector then chooses the next justified unresolved sibling or related follow-up + +For the current conservative completion rule: + +- **one resolved child** → parent becomes `provisional` with higher confidence, but remains unresolved +- **all direct child unknowns resolved** → parent resolves deterministically with `high` confidence + +The confidence model is now explicitly separated into: + +- **evidence confidence**: how trustworthy the currently attached support is +- **completeness**: whether the required direct child structure is empty, partial, or complete +- **conclusion confidence**: how strongly the current parent state is justified given both evidence and completeness + +Deterministic propagation rules now enforce: + +- one resolved child may raise evidence confidence +- unresolved direct children cap conclusion confidence +- contradictory direct children block high conclusion confidence +- duplicate evidence does not increase confidence +- status changes do not raise confidence on their own +- parent resolution still requires the separate completion rule + +## Cross-branch corroboration + +The next confidence experiment adds deterministic branch interaction checks without changing the graph model. + +The engine now distinguishes between: + +- **multiple evidence**: more than one branch exists +- **independent corroboration**: distinct resolved branches support the same parent without sharing the same evidence key +- **duplicate evidence**: the same evidence key appears through multiple branches and must not be double-counted +- **conflicting evidence**: branches support incompatible positions, such as `recognised correctly` vs `recognised incorrectly` + +Deterministic branch rules: + +- corroboration only counts when branches are distinct and their evidence sources differ +- duplicate evidence groups never count as corroboration +- conflicts cap conclusion confidence and prevent a higher confidence upgrade +- independent branches remain interaction-neutral + +Additional diagnostics now expose: + +- `corroboratingBranchCount` +- `conflictingBranchCount` +- `duplicateEvidenceCount` +- `independentBranchCount` +- `interactionSummary` +- `confidenceAdjustmentReason` + +Observed effect: + +- independent corroboration can raise `evidenceConfidence` +- duplicate evidence produces no extra confidence increase +- conflicting evidence lowers or caps `conclusionConfidence` +- completeness rules still dominate whether a parent may become highly justified + +Example progression: + +- parent before: `unknown`, `medium` +- after resolving `How the two observations were measured`: parent becomes `provisional`, `medium` +- evidence confidence becomes `high`, completeness becomes `partial`, conclusion confidence becomes `medium` +- next sibling becomes selectable and the engine moves on without recreating the resolved child + +## Interpretation + +This supports the idea that recursive decomposition is a fundamental part of graph-backed questioning, not just a prompt refinement. + +The main remaining limitation is that sibling selection still inherits the existing deterministic scorer. That means some domains may advance to a justified sibling that is not the intuitively expected next child, even though the propagation itself remains deterministic and graph-valid. + +## Validation run + +Covered by: + +- `tests/graph/atomicity-assessment.test.js` +- `tests/graph/decomposition-quality.test.js` +- `tests/graph/upward-propagation.test.js` +- `tests/graph/apply-proposal.test.js` +- `tests/graph/orchestrator.test.js` +- `tests/graph/question-formulator.test.js` +- `tests/ui/scenario-form.test.jsx` + +And then by the broader requested validation pass with lint and build. diff --git a/docs/v0.6-comparability-experiment.md b/docs/v0.6-comparability-experiment.md new file mode 100644 index 0000000..b19507f --- /dev/null +++ b/docs/v0.6-comparability-experiment.md @@ -0,0 +1,48 @@ +# v0.6 Comparability Experiment + +## Hypothesis + +The engine should confirm that observations are comparable before treating their difference as a contradiction that needs explanatory follow-up. + +## Fixtures + +1. Revenue increased by 18%, but cash in the bank fell over the same period. +2. Complaints increased. Production increased. +3. Average delivery time decreased by 25%, but order cancellations increased. +4. Customer satisfaction increased, but complaints increased. +5. Temperature increased. Ice melted. +6. Sales doubled. Sales doubled. + +## Results + +- The first four scenarios repeated the same failure pattern: contradiction-level investigation could begin before comparability was established. +- A deterministic comparability gate corrected that by producing one comparison question first. +- Confirmed comparability did not by itself imply contradiction. +- Temperature increased / Ice melted was reclassified as a compatible relationship, so no contradiction question was asked. +- Sales doubled / Sales doubled was reclassified as duplicate observations, so no follow-up question was asked. + +## Relationship classification stage + +After comparability assessment, observations now pass through a deterministic relationship classification stage: + +- `contradictory` +- `compatible` +- `potentially_related` +- `duplicate` +- `insufficient_information` + +## Whether comparability should become a permanent reasoning stage + +Yes, in minimal deterministic form. + +The repeated pattern appeared in four scenarios, so a small pre-contradiction comparability assessment is justified. + +## Two-step experiment result + +A comparison question is useful only if its answer advances the reasoning stage rather than merely adding more text. + +In the revenue-versus-cash scenario, the first question now confirms whether the figures are comparable, and the answer resolves that existing uncertainty instead of creating a parallel note. After that update, the engine progresses from comparability assessment to cautious relationship assessment and can select one broad non-expert follow-up question. + +Every justified next question should correspond to an explicit unresolved graph node. + +The earlier fallback-only path has now been removed from the normal successful progression. After comparability is resolved and a further investigation question is justified, the engine creates or reuses an explicit unresolved reasoning unknown and lets deterministic selection and question formulation proceed through the standard graph pipeline. A fallback is now only acceptable as an explicit failure case, not as the normal source of the next question. diff --git a/docs/v0.6-reasoning-architecture.md b/docs/v0.6-reasoning-architecture.md new file mode 100644 index 0000000..0c456d6 --- /dev/null +++ b/docs/v0.6-reasoning-architecture.md @@ -0,0 +1,375 @@ +# v0.6 Reasoning Architecture + +## Purpose + +This document describes the implemented deterministic reasoning architecture on branch `feature/question-strategy-alignment-v0.6`. + +It is written for future developers who need to understand how v0.6 actually executes, what invariants it relies on, where the recursive loops are, and what the system deliberately does **not** attempt to do. + +## End-to-end pipeline + +The implemented runtime pipeline is: + +```text +Scenario input +↓ +LLM analysis / reconstruction +↓ +Initial graph build +↓ +Deterministic unknown selection +↓ +Selected question +↓ +User answer +↓ +LLM graph-update proposal +↓ +Proposal parsing / normalisation +↓ +Proposal compatibility validation +↓ +Deterministic graph update application +↓ +Reasoning-state rebuild +↓ +Comparability assessment +↓ +Relationship classification +↓ +Explicit emergent unknown creation / reuse (if required) +↓ +Deterministic reselection +↓ +Atomicity assessment +↓ +Optional decomposition into child unknowns +↓ +Deterministic reselection +↓ +Resolved-child propagation upward +↓ +Confidence / completeness / corroboration update +↓ +Next active unknown +↓ +Question formulation +``` + +## Deterministic stages + +### 1. Scenario analysis / reconstruction + +- **Purpose**: obtain structured reconstruction material from scenario text +- **Input**: scenario, prompt version +- **Output**: analysis payload containing reconstruction, evidence, diagnostics, and optional next question +- **Why it exists**: provides the initial structured substrate from which the graph is built +- **What breaks if removed**: the graph builder has no structured reconstruction to convert into nodes and edges + +### 2. Initial graph build + +- **Purpose**: convert reconstruction output into an initial `SituationGraph` +- **Input**: reconstruction + evidence +- **Output**: graph nodes and edges, then `makeGraph(...)` wraps them with active/resolved/summary state +- **Why it exists**: all later reasoning is graph-based, not free text +- **What breaks if removed**: no explicit unknown nodes, no deterministic selection, no validated update loop + +### 3. Deterministic unknown selection + +- **Purpose**: choose the next active unknown from unresolved graph nodes +- **Input**: graph, resolved node IDs +- **Output**: selected candidate or explicit ambiguity result +- **Why it exists**: the system needs a deterministic next investigation target +- **What breaks if removed**: question ordering becomes arbitrary or hidden in prompts + +### 4. Selected question exposure + +- **Purpose**: expose the chosen unknown as the next question to the user +- **Input**: selected unknown + question formulation or tie-resolution logic +- **Output**: selected question object +- **Why it exists**: the user-facing loop must ask a concrete next question +- **What breaks if removed**: the system can build a graph but cannot continue interaction coherently + +### 5. LLM graph-update proposal + +- **Purpose**: transform a user answer into a proposed graph change set +- **Input**: current graph, previous question, answer, prompt version +- **Output**: raw JSON-like proposal +- **Why it exists**: the LLM is limited to proposing changes; it does not mutate the graph directly +- **What breaks if removed**: answers cannot affect the graph except through manual hard-coded logic + +### 6. Proposal parsing / normalisation + +- **Purpose**: parse JSON, remove null array items, apply known aliases, fill omitted optional fields +- **Input**: raw model response +- **Output**: validated `graphUpdateSchema` payload or structured parser failure +- **Why it exists**: model outputs are not trusted as-is +- **What breaks if removed**: malformed or partially missing model output would reach graph logic directly + +### 7. Proposal compatibility validation + +- **Purpose**: ensure the proposal is graph-safe and semantically valid before application +- **Input**: current graph + proposed update +- **Output**: accepted proposal or compatibility errors +- **Why it exists**: protects graph integrity and reasoning invariants +- **What breaks if removed**: duplicate IDs, missing references, fake selected questions, and no-op updates could corrupt the graph + +### 8. Deterministic graph update application + +- **Purpose**: apply only validated graph changes to a copied graph +- **Input**: graph + validated proposal +- **Output**: updated nodes, edges, resolved node IDs +- **Why it exists**: separates safe application from generation +- **What breaks if removed**: no explicit, replayable state transition exists + +### 9. Reasoning-state rebuild + +- **Purpose**: derive fresh comparability/relationship state from the updated graph +- **Input**: updated graph + optional override state +- **Output**: `reasoningState` +- **Why it exists**: reasoning stages are derived from graph state, not stored blindly +- **What breaks if removed**: comparability and relationship decisions drift from actual graph contents + +### 10. Comparability assessment + +- **Purpose**: decide whether supported observations are comparable enough for relationship reasoning +- **Input**: graph observations + central statement + optional stored override +- **Output**: comparability status/reason + contradiction permission +- **Why it exists**: relationship reasoning is gated by comparability +- **What breaks if removed**: contradiction or relationship reasoning would run over incomparable observations + +### 11. Relationship classification + +- **Purpose**: classify observation relationships once comparability permits it +- **Input**: graph + comparability result +- **Output**: relationship status, reason, whether a follow-up question is justified +- **Why it exists**: determines whether explanation-style follow-up is needed +- **What breaks if removed**: the system cannot distinguish compatible, duplicate, insufficient, and contradiction-adjacent observation sets + +### 12. Explicit emergent unknown creation / reuse + +- **Purpose**: ensure any justified relationship follow-up is represented by an explicit unresolved graph node +- **Input**: provisional graph + relationship assessment +- **Output**: reused or newly added explanation unknown and edges +- **Why it exists**: preserves the invariant that a question must originate from an explicit unknown +- **What breaks if removed**: relationship follow-up would revert to fallback-only question text not backed by the graph + +### 13. Atomicity assessment + +- **Purpose**: determine whether the selected unknown is directly investigable or too composite +- **Input**: selected unknown + graph context +- **Output**: `atomic` or `composite` decision with decomposition kind/reason +- **Why it exists**: prevents asking broad explanation unknowns directly +- **What breaks if removed**: the system asks high-level composite unknowns instead of decomposing them first + +### 14. Optional decomposition + +- **Purpose**: split a composite unknown into deterministic child unknowns +- **Input**: composite selected unknown + graph context +- **Output**: 2–5 child unknowns, edges, quality summary, rejection diagnostics +- **Why it exists**: narrows broad unknowns into explicit candidate dimensions +- **What breaks if removed**: recursive reasoning stops at broad parents and loses graph-backed substructure + +### 15. Resolved-child propagation upward + +- **Purpose**: move resolved child effects to parent and ancestor chain without prematurely resolving them +- **Input**: updated graph + proposal snapshot +- **Output**: parent/ancestor status and confidence updates, additional diagnostics +- **Why it exists**: decomposition requires deterministic reconstruction as well as decomposition +- **What breaks if removed**: child answers stay local and parents never become progressively better-supported + +### 16. Confidence / completeness / corroboration update + +- **Purpose**: derive parent-level `confidenceAssessment` from resolved children and branch interactions +- **Input**: parent child set + branch evidence/status interactions +- **Output**: `evidenceConfidence`, `completenessStatus`, `conclusionConfidence`, plus derived display `confidence` +- **Why it exists**: reasoning support must be separated from completion and contradiction state +- **What breaks if removed**: parent confidence collapses back into vague status-driven heuristics + +### 17. Next active unknown + question formulation + +- **Purpose**: reselect the next unresolved unknown and formulate a concrete next question +- **Input**: updated graph + selection state + graph context +- **Output**: next active unknown and question object +- **Why it exists**: closes the recursive interaction loop +- **What breaks if removed**: the system updates the graph but cannot continue investigation deterministically + +## Architectural invariants + +The current implementation enforces these invariants: + +1. **A question must originate from an explicit unresolved unknown node.** +2. **Unknown selection is deterministic.** +3. **Alphabetical ordering is not treated as reasoning.** +4. **Relationship reasoning does not precede comparability.** +5. **The LLM never mutates the graph directly; it only proposes updates.** +6. **All graph updates are schema-validated before application.** +7. **All node/edge references must resolve to existing nodes.** +8. **Duplicate node IDs are rejected.** +9. **Duplicate added edge IDs are rejected.** +10. **A selected question cannot target a resolved unknown.** +11. **A resolved unknown updated to `resolved` must also appear in `resolvedUnknownNodeIds`.** +12. **A proposal must contain a meaningful change.** +13. **Every newly added unknown must include why-it-matters language.** +14. **Every newly added unknown must be explicitly connected to answer-derived graph structure.** +15. **Composite selected unknowns are decomposed before direct questioning when atomicity rules require it.** +16. **Parent unknowns remain unresolved until completion rules are satisfied.** +17. **Confidence must not outrun completeness.** +18. **Duplicate evidence cannot increase confidence.** +19. **Conflicting evidence caps conclusion confidence.** +20. **Cross-branch corroboration only counts for distinct branches with distinct evidence keys.** +21. **Ambiguous leading unknowns remain explicit ambiguity, not silent forced choice.** + +## Recursive loops and stopping rules + +### Main investigation loop + +```text +Unknown +↓ +Question +↓ +Answer +↓ +Proposal +↓ +Graph update +↓ +Propagation +↓ +Next unknown +``` + +- **Exit condition**: no unresolved candidates remain, or no next question is justified, or proposal/application fails +- **Stopping rule**: deterministic selection returns `null` or explicit ambiguity, or update validation blocks progress +- **Completion behaviour**: continues only while the graph contains justified unresolved unknowns + +### Decomposition loop + +```text +Selected unknown +↓ +Atomicity assessment +↓ +If composite: decompose +↓ +Reselect child +↓ +Atomicity assessment again +``` + +- **Exit condition**: selected child is atomic; parent already has children; max decomposition depth reached; or decomposition quality fails +- **Stopping rule**: `MAX_DECOMPOSITION_DEPTH`, reuse instead of regeneration, or inability to produce enough valid child unknowns +- **Completion behaviour**: deterministic and bounded; no infinite recursive decomposition path is intentionally allowed + +### Propagation loop + +```text +Resolved child +↓ +Ancestor chain walk +↓ +Recompute parent state +↓ +Stop when no ancestor state changes +``` + +- **Exit condition**: no more parents in the ancestor chain or no state change +- **Stopping rule**: ancestor chain is explicit and finite; propagation does not invent new ancestors +- **Completion behaviour**: deterministic upward traversal with explicit stop on unchanged state + +### Potential infinite loops reviewed + +- **Unknown/question recursion**: bounded by unresolved unknown set, proposal validation, and explicit no-candidate states +- **Decomposition recursion**: bounded by max depth and child reuse rules +- **Propagation recursion**: bounded by finite ancestor chain and no-change stop condition + +No intentional infinite reasoning loop is present in the implemented architecture. + +## Graph lifecycle summary + +### Node lifecycle + +1. node created by `buildInitialGraph` or later proposal/decomposition/emergent-unknown logic +2. node validated by schema +3. node may become active unknown +4. node may be updated by proposal application +5. unknown node may become `resolved`, `provisional`, `contradicted`, or remain `unknown` +6. resolved unknown ID is tracked in `resolvedNodeIds` + +### Edge lifecycle + +1. edge created in initial graph or by deterministic proposal augmentation +2. edge validated against existing node IDs +3. edge may be removed only through explicit `removedEdgeIds` +4. edge relationships also update `dependsOn` / `childIds` projections during application + +### Unknown lifecycle + +1. initial unknown discovered from reconstruction +2. selected deterministically or left ambiguous +3. may be decomposed if composite +4. may be resolved directly by answer +5. may cause emergent reasoning unknown creation when relationship reasoning demands a new explicit question target + +### Resolved lifecycle + +1. proposal marks unresolved unknown resolved +2. reconciliation ensures resolution semantics are explicit +3. `resolvedUnknownNodeIds` feed graph application +4. propagation may resolve parent only when completion rule is met + +### Confidence lifecycle + +1. nodes begin with base `confidence` +2. parent/ancestor propagation derives `confidenceAssessment` +3. display `confidence` is derived from `conclusionConfidence` +4. completeness, duplicate evidence, contradiction, and corroboration constrain the result + +### Question lifecycle + +1. selected unknown becomes question target +2. `formulateQuestion` or tie-resolution logic produces question text +3. answer returns through update route +4. proposal may select a new question target or leave reselection to deterministic logic + +Every major transition above is explicit in the current codebase rather than implicit in model text alone. + +## Duplicated or overlapping concepts + +The following concepts are intentionally close and may look duplicated: + +- **status vs confidence**: status captures lifecycle/progression; confidence captures support strength +- **confidence vs confidenceAssessment**: `confidence` is now a derived display field, while `confidenceAssessment` carries separated reasoning dimensions +- **resolvedNodeIds vs node.status === resolved**: both are maintained; the first is a graph-level index, the second is node-local state +- **selectedQuestion in proposal vs selectedQuestion in final result**: proposal may omit or propose one, final result recomputes deterministic selection/questioning after graph logic +- **comparability state in reasoningState vs derived comparability from graph**: overrides may carry forward prior confirmed reasoning, but `buildReasoningState` still rebuilds from graph + override context + +These are not necessarily defects, but they are the main places where future simplification pressure is likely. + +## Known boundaries and deliberate exclusions + +v0.6 deliberately does **not** attempt the following: + +- probabilistic reasoning +- Bayesian inference +- persistence +- semantic embeddings +- fuzzy semantic similarity +- autonomous exploration outside explicit user answers +- multi-hop corroboration across unrelated subtrees without a shared direct parent +- expert-only jargon-specific reasoning modes +- UI-heavy reasoning visualisation beyond existing graph/update displays +- arbitrary non-deterministic tie breaking + +## Defects found during this review + +No new production defect was intentionally introduced or fixed as part of this architecture review. + +## Developer notes + +- `startCase` owns reconstruction → graph build → first deterministic selection. +- `updateCaseWithDependencies` owns proposal generation / parsing and delegates deterministic graph semantics to `applyValidatedProposal`. +- `applyValidatedProposal` is the main reasoning pipeline coordinator for update-time graph evolution. +- `question-formulator.js` owns comparability, relationship classification, atomicity assessment, investigation strategy selection, and question formulation. +- `utils.js` owns selection scoring, ordering, graph validation, and safe graph update application. diff --git a/docs/v0.6-release-notes.md b/docs/v0.6-release-notes.md new file mode 100644 index 0000000..e627b6a --- /dev/null +++ b/docs/v0.6-release-notes.md @@ -0,0 +1,89 @@ +# v0.6 Release Notes + +## Purpose + +v0.6 turns the engine into a deterministic recursive reasoning system that keeps next questions, decomposition, propagation, and confidence updates explicitly grounded in the situation graph. + +## Capabilities added + +- deterministic unknown selection explanations +- explicit ambiguity handling instead of silent tie-breaking +- comparability assessment before relationship reasoning +- relationship classification after comparability +- reasoning-stage progression after comparability answers +- graph-backed next questions via explicit unknown nodes +- investigation-strategy-based question formulation +- atomicity assessment for selected unknowns +- composite-unknown decomposition into child unknowns +- child-quality validation for decomposition outputs +- upward propagation from resolved children to parents and ancestors +- separation of evidence confidence, completeness, and conclusion confidence +- deterministic cross-branch corroboration, conflict, and duplicate-evidence handling +- developer-facing reasoning architecture documentation + +## Reasoning pipeline summary + +```text +Scenario +→ Reconstruction +→ Initial graph +→ Deterministic unknown selection +→ Question +→ Answer +→ Proposal +→ Proposal parsing / validation +→ Graph update +→ Reasoning-state rebuild +→ Comparability assessment +→ Relationship classification +→ Emergent unknown creation / reuse +→ Atomicity assessment +→ Optional decomposition +→ Propagation +→ Confidence / completeness / corroboration update +→ Next active unknown +→ Next question +``` + +## Core invariants + +- every asked question must originate from an explicit unresolved unknown +- unknown selection is deterministic +- ambiguity is preserved explicitly when no justified distinction exists +- relationship reasoning cannot precede comparability +- parent nodes cannot resolve before completion rules are met +- confidence cannot outrun completeness +- duplicate evidence cannot increase confidence +- conflicting evidence caps conclusion confidence +- cross-branch corroboration only counts for distinct branches with distinct evidence keys +- the LLM proposes updates but does not mutate the graph directly + +## What v0.6 proved + +- graph-backed questioning works better when every justified next question maps to an explicit unresolved node +- broad unknowns can be decomposed deterministically before direct questioning +- resolved child evidence can be propagated upward without prematurely resolving parent reasoning +- confidence becomes easier to reason about when evidence quality, completeness, and conclusion strength are separated +- deterministic cross-branch corroboration can improve support without double-counting repeated evidence + +## Known limitations + +- sibling selection still depends on the existing deterministic scorer and may choose a justified next branch that is not always the intuitively expected one +- cross-branch corroboration is limited to direct child branches of the same parent +- no multi-hop corroboration exists across unrelated subtrees +- reasoning remains bounded to explicitly represented graph structure and user-provided answers + +## Deliberate exclusions + +- no persistence +- no autonomous exploration +- no probabilistic reasoning +- no Bayesian reasoning +- no semantic embeddings +- no expert mode +- no multi-hop corroboration across unrelated subtrees +- no heavy graph visualisation + +## Next experimental question + +`Can the engine preserve and reuse successful reasoning structures across separate cases without turning prior experience into unquestioned assumptions?` diff --git a/docs/v0.6-selection-influence-experiment.md b/docs/v0.6-selection-influence-experiment.md new file mode 100644 index 0000000..6d1dfbf --- /dev/null +++ b/docs/v0.6-selection-influence-experiment.md @@ -0,0 +1,50 @@ +# v0.6 Selection Influence Experiment + +## Hypothesis + +The initial unknown selected for the revenue-versus-cash scenario may be driven more by graph structure, more by semantic keyword matches, or by both together. + +## Scenario + +`Revenue increased by 18%, but cash in the bank fell over the same period.` + +## Actual selected node + +- Node ID: `nqdzobz` +- Label: `Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).` +- Deterministic investigation strategy: `definition` +- Deterministic question: `What evidence would resolve whether magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts). is true?` + +## Structural contribution + +- Downstream dependency count: `0` +- Prerequisite position: no unresolved prerequisites; count `0` +- Dependency ordering / centrality: no candidate had downstream dependants or dependency depth advantage in the live graph + +## Semantic contribution + +- Objective: false +- Actor: false +- Criteria: false +- Measurement: false +- Terminology: false +- Constraint: false +- Pricing: false +- Implementation: false +- Optimisation: false +- Speculative: false +- Contribution list: only `downstream_dependencies` was present, with delta `0` + +## Counterfactual results + +- Live-shaped ordering: `nqdzobz` ranked above `niewza`, but both had score `0`, downstream `0`, and unresolved prerequisites `0` +- Links removed: ordering stayed the same, because the live graph already provided no differentiating structure between the two unknowns +- Wording neutralised: ordering flipped to the first unknown by neutral label order (`Unknown A` before `Unknown B`), showing the outcome remained tie-break-driven rather than structure-driven + +## Conclusion + +For this scenario, the actual winner was not selected because of graph structure and not selected because of semantic keyword weights. The live diagnostics show a complete tie on score, downstream influence, and prerequisite position, with every semantic match category false for both candidates. The winner was therefore chosen by the final tie-break rule, `label_asc`. + +## Is a scoring change justified? + +Not from this single experiment alone. The result shows a diagnostic gap for this scenario, but this task does not justify a scoring change by itself, and no scoring change is made. diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index da727f8..67be4d1 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -1,6 +1,16 @@ import { describeGraph } from "./builder.js"; -import { formulateQuestion } from "./question-formulator.js"; -import { graphUpdateSchema, situationGraphSchema } from "./schema.js"; +import { + assessUnknownAtomicity, + buildReasoningState, + classifyObservationRelationship, + COMPARABILITY_REASONING_NODE_ID, + formulateQuestion, +} from "./question-formulator.js"; +import { + graphUpdateSchema, + makeNodeId, + situationGraphSchema, +} from "./schema.js"; import { applyGraphUpdate, detectDuplicateNodeIds, @@ -423,7 +433,1336 @@ function buildChangesApplied(proposal, affectedNodeIds) { }; } -export function applyValidatedProposal({ situationGraph, proposal }) { +function appendUniqueValue(values = [], nextValue) { + return nextValue && !values.includes(nextValue) + ? [...values, nextValue] + : values; +} + +function getNodeConfidenceAssessment(node) { + return ( + node?.confidenceAssessment || { + evidenceConfidence: node?.confidence ?? "medium", + completenessStatus: + node?.status === "resolved" + ? "complete" + : node?.status === "provisional" + ? "partial" + : "empty", + conclusionConfidence: + node?.status === "resolved" + ? (node?.confidence ?? "high") + : node?.status === "provisional" + ? (node?.confidence ?? "medium") + : "low", + } + ); +} + +function confidenceFromAssessment(assessment) { + return assessment?.conclusionConfidence ?? "medium"; +} + +function unique(values = []) { + return [...new Set(values.filter(Boolean))]; +} + +function branchEvidenceKeys(node) { + return unique([...(node?.evidenceIds || []), node?.value]); +} + +function sharedMeaningfulTokens(aText, bText) { + const stop = new Set([ + "the", + "and", + "for", + "that", + "this", + "with", + "from", + "because", + "need", + "unknown", + "possible", + ]); + const a = splitSemanticTokens(aText).filter((token) => !stop.has(token)); + const b = splitSemanticTokens(bText).filter((token) => !stop.has(token)); + return [...new Set(a.filter((token) => b.includes(token)))]; +} + +function branchConflictSignature(node) { + return normaliseText( + `${node?.label || ""} ${node?.description || ""} ${node?.value || ""}`, + ); +} + +function branchesConflict(aNode, bNode) { + const aText = branchConflictSignature(aNode); + const bText = branchConflictSignature(bNode); + const oppositePolarity = + (aText.includes("correctly") && bText.includes("incorrectly")) || + (aText.includes("incorrectly") && bText.includes("correctly")) || + aNode?.status === "contradicted" || + bNode?.status === "contradicted"; + + if (!oppositePolarity) return false; + + return sharedMeaningfulTokens(aText, bText).length >= 2; +} + +export function evaluateBranchInteractions({ parentNode, graph }) { + const directBranches = findDirectChildUnknowns(graph, parentNode.id).filter( + (node) => ["resolved", "provisional", "contradicted"].includes(node.status), + ); + const duplicateEvidenceGroups = []; + const conflictingBranches = []; + const corroboratingBranches = []; + const duplicateBranchIds = new Set(); + const conflictingBranchIds = new Set(); + + const evidenceGroups = new Map(); + for (const branch of directBranches) { + for (const evidenceKey of branchEvidenceKeys(branch)) { + const ids = evidenceGroups.get(evidenceKey) || []; + ids.push(branch.id); + evidenceGroups.set(evidenceKey, ids); + } + } + + for (const [evidenceKey, branchIds] of evidenceGroups.entries()) { + if (branchIds.length > 1) { + duplicateEvidenceGroups.push({ + evidenceKey, + branchIds: unique(branchIds), + }); + for (const id of branchIds) duplicateBranchIds.add(id); + } + } + + for (let index = 0; index < directBranches.length; index += 1) { + for (let inner = index + 1; inner < directBranches.length; inner += 1) { + const aNode = directBranches[index]; + const bNode = directBranches[inner]; + if (branchesConflict(aNode, bNode)) { + conflictingBranches.push([aNode.id, bNode.id]); + conflictingBranchIds.add(aNode.id); + conflictingBranchIds.add(bNode.id); + continue; + } + + const aEvidence = branchEvidenceKeys(aNode); + const bEvidence = branchEvidenceKeys(bNode); + const sharesEvidence = aEvidence.some((key) => bEvidence.includes(key)); + if ( + !sharesEvidence && + aNode.status === "resolved" && + bNode.status === "resolved" + ) { + corroboratingBranches.push([aNode.id, bNode.id]); + } + } + } + + const interactionBranchIds = new Set([ + ...duplicateBranchIds, + ...conflictingBranchIds, + ...corroboratingBranches.flat(), + ]); + const independentBranches = directBranches + .map((branch) => branch.id) + .filter((id) => !interactionBranchIds.has(id)); + + return { + corroboratingBranches, + conflictingBranches, + duplicateEvidenceGroups, + independentBranches, + interactionSummary: { + corroboratingBranchCount: corroboratingBranches.length, + conflictingBranchCount: conflictingBranches.length, + duplicateEvidenceCount: duplicateEvidenceGroups.length, + independentBranchCount: independentBranches.length, + }, + }; +} + +function upsertProposalNodeUpdate(proposalSnapshot, update) { + const existing = proposalSnapshot.updatedNodes.find( + (candidate) => candidate.nodeId === update.nodeId, + ); + + if (existing) { + if (update.newStatus != null) existing.newStatus = update.newStatus; + if (update.newValue !== undefined) existing.newValue = update.newValue; + if (existing.previousStatus == null) { + existing.previousStatus = update.previousStatus ?? null; + } + if (existing.previousValue === undefined) { + existing.previousValue = update.previousValue ?? null; + } + existing.reason = update.reason; + return existing; + } + + proposalSnapshot.updatedNodes.push(update); + return update; +} + +function ensureResolvedUnknownId(proposalSnapshot, nodeId) { + if (!proposalSnapshot.resolvedUnknownNodeIds.includes(nodeId)) { + proposalSnapshot.resolvedUnknownNodeIds.push(nodeId); + } +} + +function buildPropagationEvidenceId(nodeId) { + return `answer:${nodeId}`; +} + +function findDirectChildUnknowns(graph, parentNodeId) { + const parentNode = (graph.nodes || []).find( + (node) => node.id === parentNodeId, + ); + const childIds = new Set(parentNode?.childIds || []); + + for (const edge of graph.edges || []) { + if (edge.toNodeId === parentNodeId && edge.relationship === "depends_on") { + childIds.add(edge.fromNodeId); + } + } + + return (graph.nodes || []).filter( + (node) => + node.kind === "unknown" && + (node.parentId === parentNodeId || childIds.has(node.id)), + ); +} + +function hasExistingDecompositionChildren(graph, parentNodeId) { + return findDirectChildUnknowns(graph, parentNodeId).length > 0; +} + +function buildAncestorChain(graph, node) { + const nodesById = new Map((graph.nodes || []).map((item) => [item.id, item])); + const chain = []; + const queue = [node?.parentId ?? null].filter(Boolean); + const seen = new Set(); + + while (queue.length > 0) { + const currentParentId = queue.shift(); + if (!currentParentId || seen.has(currentParentId)) continue; + seen.add(currentParentId); + + const parentNode = nodesById.get(currentParentId); + if (!parentNode) continue; + chain.push(parentNode); + + if (parentNode.parentId) { + queue.push(parentNode.parentId); + } + + for (const candidate of graph.nodes || []) { + if ( + candidate.id !== parentNode.id && + (candidate.childIds || []).includes(parentNode.id) + ) { + queue.push(candidate.id); + } + } + } + + return chain; +} + +function syncParentChildReferences(graph) { + const nodesById = new Map((graph.nodes || []).map((node) => [node.id, node])); + + for (const node of graph.nodes || []) { + if (!node.parentId) continue; + const parentNode = nodesById.get(node.parentId); + if (!parentNode) continue; + + parentNode.childIds = appendUniqueValue(parentNode.childIds || [], node.id); + parentNode.dependsOn = appendUniqueValue( + parentNode.dependsOn || [], + node.id, + ); + } + + return graph; +} + +function computeParentProgressState(graph, parentNode) { + const childUnknowns = findDirectChildUnknowns(graph, parentNode.id); + const resolvedChildren = childUnknowns.filter( + (child) => child.status === "resolved", + ); + const progressedChildren = childUnknowns.filter((child) => + ["resolved", "provisional"].includes(child.status), + ); + const contradictoryChildren = childUnknowns.filter( + (child) => child.status === "contradicted", + ); + const totalChildren = childUnknowns.length; + const resolvedCount = resolvedChildren.length; + const unresolvedCount = childUnknowns.filter( + (child) => child.status !== "resolved", + ).length; + const beforeAssessment = getNodeConfidenceAssessment(parentNode); + const interactions = evaluateBranchInteractions({ parentNode, graph }); + const corroborationCount = + interactions.interactionSummary.corroboratingBranchCount; + const duplicateEvidenceCount = + interactions.interactionSummary.duplicateEvidenceCount; + const conflictingBranchCount = + interactions.interactionSummary.conflictingBranchCount; + + if (totalChildren === 0) { + const nextAssessment = { + evidenceConfidence: beforeAssessment.evidenceConfidence, + completenessStatus: beforeAssessment.completenessStatus, + conclusionConfidence: beforeAssessment.conclusionConfidence, + }; + return { + totalChildren, + resolvedChildren, + progressedChildren, + contradictoryChildren, + nextStatus: parentNode.status, + nextConfidence: confidenceFromAssessment(nextAssessment), + nextConfidenceAssessment: nextAssessment, + parentResolved: parentNode.status === "resolved", + confidenceCapReason: "no_child_structure", + reason: "Parent has no child unknowns to aggregate.", + }; + } + + let nextAssessment; + let confidenceCapReason; + + if (contradictoryChildren.length > 0) { + nextAssessment = { + evidenceConfidence: resolvedCount > 0 ? "medium" : "low", + completenessStatus: resolvedCount === 0 ? "empty" : "partial", + conclusionConfidence: "low", + }; + confidenceCapReason = "contradictory_direct_children"; + } else if (resolvedCount === 0) { + nextAssessment = { + evidenceConfidence: "low", + completenessStatus: "empty", + conclusionConfidence: "low", + }; + confidenceCapReason = "no_resolved_direct_children"; + } else if (resolvedCount < totalChildren) { + nextAssessment = { + evidenceConfidence: corroborationCount > 0 ? "high" : "medium", + completenessStatus: "partial", + conclusionConfidence: "medium", + }; + confidenceCapReason = + conflictingBranchCount > 0 + ? "conflicting_branches_cap_conclusion" + : duplicateEvidenceCount > 0 + ? "duplicate_evidence_no_extra_confidence" + : corroborationCount > 0 + ? "independent_corroboration_with_incomplete_parent" + : "unresolved_direct_children_cap_conclusion"; + } else { + nextAssessment = { + evidenceConfidence: "high", + completenessStatus: "complete", + conclusionConfidence: conflictingBranchCount > 0 ? "low" : "high", + }; + confidenceCapReason = + conflictingBranchCount > 0 + ? "conflicting_branches_cap_conclusion" + : duplicateEvidenceCount > 0 + ? "duplicate_evidence_no_extra_confidence" + : corroborationCount > 0 + ? "independent_corroboration_supported_conclusion" + : null; + } + + if (resolvedChildren.length === totalChildren) { + return { + totalChildren, + resolvedChildren, + progressedChildren, + contradictoryChildren, + nextStatus: "resolved", + nextConfidence: confidenceFromAssessment(nextAssessment), + nextConfidenceAssessment: nextAssessment, + parentResolved: true, + resolvedDirectChildren: resolvedCount, + unresolvedDirectChildren: unresolvedCount, + contradictoryDirectChildren: contradictoryChildren.length, + branchInteractions: interactions, + confidenceCapReason, + reason: + "All direct child unknowns are resolved, so the parent can now resolve deterministically.", + }; + } + + if (progressedChildren.length > 0) { + return { + totalChildren, + resolvedChildren, + progressedChildren, + contradictoryChildren, + nextStatus: "provisional", + nextConfidence: confidenceFromAssessment(nextAssessment), + nextConfidenceAssessment: nextAssessment, + parentResolved: false, + resolvedDirectChildren: resolvedCount, + unresolvedDirectChildren: unresolvedCount, + contradictoryDirectChildren: contradictoryChildren.length, + branchInteractions: interactions, + confidenceCapReason, + reason: + "At least one direct child has been progressed, so the parent becomes provisional but remains unresolved until all direct children are resolved.", + }; + } + + return { + totalChildren, + resolvedChildren, + progressedChildren, + contradictoryChildren, + nextStatus: parentNode.status, + nextConfidence: confidenceFromAssessment(nextAssessment), + nextConfidenceAssessment: nextAssessment, + parentResolved: parentNode.status === "resolved", + resolvedDirectChildren: resolvedCount, + unresolvedDirectChildren: unresolvedCount, + contradictoryDirectChildren: contradictoryChildren.length, + branchInteractions: interactions, + confidenceCapReason, + reason: "No direct child progress exists yet for the parent.", + }; +} + +export function propagateResolvedChildEvidence({ + updatedSituationGraph, + proposalSnapshot, +}) { + const resolvedChildNodes = (updatedSituationGraph.nodes || []).filter( + (node) => + node.kind === "unknown" && + node.parentId && + proposalSnapshot.resolvedUnknownNodeIds.includes(node.id), + ); + + if (resolvedChildNodes.length === 0) { + return { + graph: updatedSituationGraph, + proposalSnapshot, + propagationPerformed: false, + resolvedChildNodeId: null, + parentNodeId: null, + parentStatusBefore: null, + parentStatusAfter: null, + parentConfidenceBefore: null, + parentConfidenceAfter: null, + evidenceConfidenceBefore: null, + evidenceConfidenceAfter: null, + completenessBefore: null, + completenessAfter: null, + conclusionConfidenceBefore: null, + conclusionConfidenceAfter: null, + resolvedDirectChildren: 0, + unresolvedDirectChildren: 0, + contradictoryDirectChildren: 0, + confidenceCapReason: null, + ancestorPropagationStoppedReason: "no_resolved_child_propagation_needed", + affectedAncestorIds: [], + nextSelectedSibling: null, + parentResolved: false, + reason: "No resolved decomposition child required upward propagation.", + }; + } + + const graph = cloneJsonSafe(updatedSituationGraph); + syncParentChildReferences(graph); + const propagationEvents = []; + const affectedAncestorIds = new Set(); + let ancestorPropagationStoppedReason = "no_ancestor_state_changed"; + + for (const resolvedChildNode of resolvedChildNodes) { + const liveChildNode = graph.nodes.find( + (node) => node.id === resolvedChildNode.id, + ); + if (!liveChildNode) continue; + + liveChildNode.evidenceIds = appendUniqueValue( + liveChildNode.evidenceIds || [], + buildPropagationEvidenceId(liveChildNode.id), + ); + + const ancestorChain = buildAncestorChain(graph, liveChildNode); + for (const ancestorNode of ancestorChain) { + const beforeStatus = ancestorNode.status; + const beforeConfidence = ancestorNode.confidence; + const beforeAssessment = getNodeConfidenceAssessment(ancestorNode); + const progressState = computeParentProgressState(graph, ancestorNode); + + ancestorNode.status = progressState.nextStatus; + ancestorNode.confidence = progressState.nextConfidence; + ancestorNode.confidenceAssessment = + progressState.nextConfidenceAssessment; + + if ( + beforeStatus === progressState.nextStatus && + beforeConfidence === progressState.nextConfidence && + JSON.stringify(beforeAssessment) === + JSON.stringify(progressState.nextConfidenceAssessment) + ) { + continue; + } + + ancestorPropagationStoppedReason = "ancestor_state_changed"; + + if (progressState.parentResolved) { + ensureResolvedUnknownId(proposalSnapshot, ancestorNode.id); + } + + upsertProposalNodeUpdate(proposalSnapshot, { + nodeId: ancestorNode.id, + previousStatus: beforeStatus, + newStatus: progressState.nextStatus, + previousValue: ancestorNode.value ?? null, + newValue: ancestorNode.value ?? null, + reason: progressState.reason, + }); + + affectedAncestorIds.add(ancestorNode.id); + propagationEvents.push({ + resolvedChildNodeId: liveChildNode.id, + parentNodeId: ancestorNode.id, + parentStatusBefore: beforeStatus, + parentStatusAfter: progressState.nextStatus, + parentConfidenceBefore: beforeConfidence, + parentConfidenceAfter: progressState.nextConfidence, + evidenceConfidenceBefore: beforeAssessment.evidenceConfidence, + evidenceConfidenceAfter: + progressState.nextConfidenceAssessment.evidenceConfidence, + completenessBefore: beforeAssessment.completenessStatus, + completenessAfter: + progressState.nextConfidenceAssessment.completenessStatus, + conclusionConfidenceBefore: beforeAssessment.conclusionConfidence, + conclusionConfidenceAfter: + progressState.nextConfidenceAssessment.conclusionConfidence, + resolvedDirectChildren: progressState.resolvedDirectChildren, + unresolvedDirectChildren: progressState.unresolvedDirectChildren, + contradictoryDirectChildren: progressState.contradictoryDirectChildren, + corroboratingBranchCount: + progressState.branchInteractions.interactionSummary + .corroboratingBranchCount, + conflictingBranchCount: + progressState.branchInteractions.interactionSummary + .conflictingBranchCount, + duplicateEvidenceCount: + progressState.branchInteractions.interactionSummary + .duplicateEvidenceCount, + independentBranchCount: + progressState.branchInteractions.interactionSummary + .independentBranchCount, + interactionSummary: progressState.branchInteractions.interactionSummary, + confidenceCapReason: progressState.confidenceCapReason, + parentResolved: progressState.parentResolved, + reason: progressState.reason, + }); + } + } + + graph.resolvedNodeIds = [ + ...new Set([ + ...graph.resolvedNodeIds, + ...proposalSnapshot.resolvedUnknownNodeIds, + ]), + ]; + + const siblingSelection = selectActiveUnknownCandidate( + graph, + graph.resolvedNodeIds, + ); + const firstEvent = propagationEvents[0] ?? null; + + return { + graph, + proposalSnapshot, + propagationPerformed: propagationEvents.length > 0, + resolvedChildNodeId: firstEvent?.resolvedChildNodeId ?? null, + parentNodeId: firstEvent?.parentNodeId ?? null, + parentStatusBefore: firstEvent?.parentStatusBefore ?? null, + parentStatusAfter: firstEvent?.parentStatusAfter ?? null, + parentConfidenceBefore: firstEvent?.parentConfidenceBefore ?? null, + parentConfidenceAfter: firstEvent?.parentConfidenceAfter ?? null, + evidenceConfidenceBefore: firstEvent?.evidenceConfidenceBefore ?? null, + evidenceConfidenceAfter: firstEvent?.evidenceConfidenceAfter ?? null, + completenessBefore: firstEvent?.completenessBefore ?? null, + completenessAfter: firstEvent?.completenessAfter ?? null, + conclusionConfidenceBefore: firstEvent?.conclusionConfidenceBefore ?? null, + conclusionConfidenceAfter: firstEvent?.conclusionConfidenceAfter ?? null, + resolvedDirectChildren: firstEvent?.resolvedDirectChildren ?? 0, + unresolvedDirectChildren: firstEvent?.unresolvedDirectChildren ?? 0, + contradictoryDirectChildren: firstEvent?.contradictoryDirectChildren ?? 0, + corroboratingBranchCount: firstEvent?.corroboratingBranchCount ?? 0, + conflictingBranchCount: firstEvent?.conflictingBranchCount ?? 0, + duplicateEvidenceCount: firstEvent?.duplicateEvidenceCount ?? 0, + independentBranchCount: firstEvent?.independentBranchCount ?? 0, + interactionSummary: firstEvent?.interactionSummary ?? null, + confidenceCapReason: firstEvent?.confidenceCapReason ?? null, + ancestorPropagationStoppedReason, + affectedAncestorIds: [...affectedAncestorIds], + nextSelectedSibling: + siblingSelection?.status === "selected" ? siblingSelection.nodeId : null, + parentResolved: firstEvent?.parentResolved ?? false, + reason: + firstEvent?.reason ?? + "Resolved child evidence propagated upward through the decomposition chain.", + }; +} + +function buildEmergentReasoningUnknownLabel(graph) { + const central = String(graph?.centralStatement || "these observations") + .trim() + .replace(/[.?!:;]+$/g, ""); + return `Explanation for why ${central}`; +} + +function findEquivalentEmergentUnknown(graph, label, description) { + const targetId = makeNodeId(label); + const targetTexts = [normaliseText(label), normaliseText(description)].filter( + Boolean, + ); + + return (graph.nodes || []).find((node) => { + if ( + node.kind !== "unknown" || + (graph.resolvedNodeIds || []).includes(node.id) + ) { + return false; + } + + if (node.id === targetId) { + return true; + } + + const nodeTexts = [ + normaliseText(node.label), + normaliseText(node.description), + ].filter(Boolean); + + return targetTexts.some((text) => nodeTexts.includes(text)); + }); +} + +function buildEmergentReasoningUnknown(graph, relationshipAssessment) { + if (!relationshipAssessment?.relationshipAssessed) { + return null; + } + + if (!relationshipAssessment.questionRequired) { + return null; + } + + if ( + ![ + "potentially_related", + "insufficient_information", + "contradictory", + ].includes(relationshipAssessment.relationshipStatus) + ) { + return null; + } + + const label = buildEmergentReasoningUnknownLabel(graph); + const description = + "Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship."; + const existingNode = findEquivalentEmergentUnknown(graph, label, description); + if (existingNode) { + return { + created: false, + node: existingNode, + edges: [], + reason: + "Reused an existing unresolved reasoning unknown for the next investigation stage.", + }; + } + + const observationNodes = (graph.nodes || []).filter( + (node) => node.kind === "observation" && node.status === "supported", + ); + const relationshipNode = (graph.nodes || []).find( + (node) => node.kind === "relationship" && node.status === "supported", + ); + const nodeId = makeNodeId(label); + const relatedNodeIds = relationshipNode + ? [relationshipNode.id] + : observationNodes.slice(0, 2).map((node) => node.id); + + if (relatedNodeIds.length === 0) { + return null; + } + + const node = { + id: nodeId, + label, + description, + kind: "unknown", + status: "unknown", + confidence: "medium", + value: null, + unit: null, + evidenceIds: [], + dependsOn: relatedNodeIds, + affects: [], + parentId: relationshipNode?.id ?? null, + childIds: [], + }; + + const edges = relatedNodeIds.map((relatedNodeId) => ({ + id: `e-${relatedNodeId.slice(0, 6)}-${nodeId.slice(0, 6)}`, + fromNodeId: relatedNodeId, + toNodeId: nodeId, + relationship: + relationshipNode?.id === relatedNodeId ? "depends_on" : "other", + confidence: "medium", + description: + "This unresolved explanation arises from the now-assessed relationship between the observations.", + })); + + return { + created: true, + node, + edges, + reason: + "Created a new unresolved reasoning unknown so the next justified question is backed by the graph.", + }; +} + +function stripTrailingPunctuation(value) { + return String(value || "") + .trim() + .replace(/[.?!:;]+$/g, "") + .trim(); +} + +function collectSupportedObservations(graph) { + return (graph.nodes || []).filter( + (node) => node.kind === "observation" && node.status === "supported", + ); +} + +function detectObservationConcept(text) { + const normalised = normaliseText(text); + const concepts = [ + ["revenue", /\brevenue\b/], + ["cash", /\bcash\b/], + ["customer satisfaction", /\bsatisfaction\b/], + ["complaints", /\bcomplaints?\b/], + ["delivery time", /\bdelivery time\b|\bdelivery\b/], + ["cancellations", /\bcancellations?\b/], + ["traffic", /\btraffic\b/], + ["sales", /\bsales\b/], + ["production", /\bproduction\b|\boutput\b/], + ["defects", /\bdefects?\b/], + ["quality", /\bquality\b/], + ]; + + for (const [label, pattern] of concepts) { + if (pattern.test(normalised)) return label; + } + + return null; +} + +function buildDecompositionContext(graph) { + const observations = collectSupportedObservations(graph); + const firstObservation = observations[0] ?? null; + const secondObservation = observations[1] ?? null; + const firstConcept = detectObservationConcept( + `${firstObservation?.label || ""} ${firstObservation?.description || ""}`, + ); + const secondConcept = detectObservationConcept( + `${secondObservation?.label || ""} ${secondObservation?.description || ""}`, + ); + + return { + centralStatement: stripTrailingPunctuation(graph.centralStatement), + firstConcept: firstConcept || "the first signal", + secondConcept: secondConcept || "the second signal", + firstObservationLabel: stripTrailingPunctuation( + firstObservation?.label || "", + ), + secondObservationLabel: stripTrailingPunctuation( + secondObservation?.label || "", + ), + }; +} + +export const MAX_DECOMPOSITION_DEPTH = 2; + +function splitSemanticTokens(value) { + return normaliseText(value) + .split(" ") + .filter((token) => token.length > 2); +} + +function buildSemanticSignature(node) { + return normaliseText(`${node?.label || ""} ${node?.description || ""}`); +} + +function calculateTokenOverlapRatio(aTokens, bTokens) { + const a = new Set(aTokens); + const b = new Set(bTokens); + const intersection = [...a].filter((token) => b.has(token)).length; + const largest = Math.max(a.size, b.size, 1); + return intersection / largest; +} + +function detectCompoundSignals(text) { + const signals = []; + + if (/\b(and|or)\b/.test(text)) { + if ( + /\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b[^.]{0,30}\b(and|or)\b[^.]{0,30}\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital|mix|segment)\b/.test( + text, + ) + ) { + signals.push("conjoined_distinct_concepts"); + } + } + + if (/\b[a-z]+\s*\/\s*[a-z]+\b/.test(text)) { + signals.push("slash_separated_categories"); + } + + if (/,[^,]{0,20},/.test(text) || /,\s*[^,]+\s+or\s+[^,]+/.test(text)) { + signals.push("comma_separated_category_list"); + } + + if (/\btiming or measurement basis\b/.test(text)) { + signals.push("timing_or_measurement_basis"); + } + + return [...new Set(signals)]; +} + +function isDirectlyAnswerableChildText(text) { + return !/\b(explanation for why|possible causes|possible reasons|what changed|difference between|divergence|moved differently|factors behind|factors affecting)\b/.test( + text, + ); +} + +function buildRejectedChildRecord(childNode, reasons, compoundSignals) { + return { + nodeId: childNode.id, + label: childNode.label, + reasons, + compoundSignals, + }; +} + +function mergeUniqueRecords(existing = [], incoming = [], key = "nodeId") { + const merged = new Map((existing || []).map((item) => [item[key], item])); + for (const item of incoming || []) { + merged.set(item[key], item); + } + return [...merged.values()]; +} + +function cloneNode(node) { + return JSON.parse(JSON.stringify(node)); +} + +export function assessChildUnknownQuality({ + parentNode, + childNode, + siblingNodes, + graph, +}) { + const parentSignature = buildSemanticSignature(parentNode); + const childSignature = buildSemanticSignature(childNode); + const parentTokens = splitSemanticTokens(parentSignature); + const childTokens = splitSemanticTokens(childSignature); + const compoundSignals = detectCompoundSignals(childSignature); + const duplicateSiblingIds = (siblingNodes || []) + .filter((sibling) => sibling.id !== childNode.id) + .filter((sibling) => buildSemanticSignature(sibling) === childSignature) + .map((sibling) => sibling.id); + const reasons = []; + + const narrowerThanParent = + childSignature !== parentSignature && + (childTokens.length < parentTokens.length || + calculateTokenOverlapRatio(parentTokens, childTokens) < 0.8); + + const directlyAnswerable = + isDirectlyAnswerableChildText(childSignature) && + compoundSignals.length === 0; + + const independent = + duplicateSiblingIds.length === 0 && compoundSignals.length === 0; + const atomic = narrowerThanParent && directlyAnswerable && independent; + + if (!narrowerThanParent) { + reasons.push("not_narrower_than_parent"); + } + if (!directlyAnswerable) { + reasons.push("not_directly_answerable"); + } + if (compoundSignals.length > 0) { + reasons.push("compound_child"); + } + if (duplicateSiblingIds.length > 0) { + reasons.push("duplicate_sibling"); + } + if ((graph?.resolvedNodeIds || []).includes(childNode.id)) { + reasons.push("already_resolved"); + } + + return { + valid: reasons.length === 0, + atomic, + directlyAnswerable, + independent, + narrowerThanParent, + compoundSignals, + duplicateSiblingIds, + reasons, + }; +} + +function buildDecompositionChildId(parentNodeId, label) { + return makeNodeId(`${parentNodeId}:${label}`); +} + +function findEquivalentDecompositionChild( + graph, + parentNodeId, + label, + description, +) { + const targetId = buildDecompositionChildId(parentNodeId, label); + const targetTexts = [normaliseText(label), normaliseText(description)].filter( + Boolean, + ); + + return (graph.nodes || []).find((node) => { + if ( + node.kind !== "unknown" || + node.parentId !== parentNodeId || + (graph.resolvedNodeIds || []).includes(node.id) + ) { + return false; + } + + if (node.id === targetId) { + return true; + } + + const nodeTexts = [ + normaliseText(node.label), + normaliseText(node.description), + ].filter(Boolean); + + return targetTexts.some((text) => nodeTexts.includes(text)); + }); +} + +function describeObservationFocus(context, which) { + const concept = + which === "first" ? context.firstConcept : context.secondConcept; + const label = + which === "first" + ? context.firstObservationLabel + : context.secondObservationLabel; + + if (concept && !concept.startsWith("the ")) return concept; + if (label) return label.toLowerCase(); + return which === "first" ? "the first observation" : "the second observation"; +} + +function buildDecompositionTemplates(parentNode, graph, depth = 0) { + const context = buildDecompositionContext(graph); + const firstFocus = describeObservationFocus(context, "first"); + const secondFocus = describeObservationFocus(context, "second"); + + if (/\btiming or measurement basis\b/i.test(parentNode.label)) { + return [ + { + label: "Whether the two observations reflect different timing", + description: `Need to know whether the two observations reflect different timing, because that would help resolve ${context.centralStatement}.`, + }, + { + label: "How the two observations were measured", + description: `Need evidence about the measure used for each observation, because that would help resolve ${context.centralStatement}.`, + }, + ]; + } + + return [ + { + label: "Whether the two observations reflect different timing", + description: `Need to know whether the two observations reflect different timing, because that could help explain ${context.centralStatement}.`, + }, + { + label: "How the two observations were measured", + description: `Need evidence about the measure used for each observation, because that could help explain ${context.centralStatement}.`, + }, + { + label: `Possible change mainly affecting ${firstFocus}`, + description: `Need to know whether a possible change mainly affected ${firstFocus}, because that could help explain ${context.centralStatement}.`, + }, + { + label: `Possible change mainly affecting ${secondFocus}`, + description: `Need to know whether a possible change mainly affected ${secondFocus}, because that could help explain ${context.centralStatement}.`, + }, + depth === 0 + ? { + label: "Possible one-off event during the period", + description: `Need to know whether a possible one-off event happened during the period, because that could help explain ${context.centralStatement}.`, + } + : { + label: "Mix shift during the period", + description: `Need to know whether the mix of cases, customers, or items shifted during the period, because that could help explain ${context.centralStatement}.`, + }, + ]; +} + +function buildCompositeUnknownChildren(parentNode, graph, depth = 0) { + const templates = buildDecompositionTemplates(parentNode, graph, depth); + + const candidateNodes = templates.map( + (template) => + findEquivalentDecompositionChild( + graph, + parentNode.id, + template.label, + template.description, + ) || { + id: buildDecompositionChildId(parentNode.id, template.label), + label: template.label, + description: template.description, + kind: "unknown", + status: "unknown", + confidence: "medium", + value: null, + unit: null, + evidenceIds: [], + dependsOn: [], + affects: [], + parentId: parentNode.id, + childIds: [], + }, + ); + const childNodes = []; + const childEdges = []; + const childNodeIds = []; + const rejectedChildren = []; + const childQualitySummary = []; + + for (const childNode of candidateNodes) { + const quality = assessChildUnknownQuality({ + parentNode, + childNode, + siblingNodes: candidateNodes, + graph, + }); + childQualitySummary.push({ + nodeId: childNode.id, + label: childNode.label, + valid: quality.valid, + atomic: quality.atomic, + reasons: quality.reasons, + }); + + if (!quality.valid) { + rejectedChildren.push( + buildRejectedChildRecord( + childNode, + quality.reasons, + quality.compoundSignals, + ), + ); + continue; + } + + childNodeIds.push(childNode.id); + + if ((graph.nodes || []).some((node) => node.id === childNode.id)) { + continue; + } + + childNodes.push(childNode); + childEdges.push({ + id: `e-${childNode.id.slice(0, 6)}-${parentNode.id.slice(0, 6)}`, + fromNodeId: childNode.id, + toNodeId: parentNode.id, + relationship: "depends_on", + confidence: "medium", + description: + "This child unknown must be investigated before the broader parent explanation can be resolved.", + }); + } + + const acceptedChildCount = childNodeIds.length; + const proposedChildCount = candidateNodes.length; + + if (acceptedChildCount < 2) { + return { + accepted: false, + childNodes: [], + childEdges: [], + childNodeIds: [], + proposedChildCount, + acceptedChildCount, + rejectedChildren, + childQualitySummary, + reason: + acceptedChildCount === 0 + ? "Decomposition stopped because all proposed children failed quality checks." + : "Decomposition stopped because fewer than two valid child unknowns remained after quality checks.", + }; + } + + return { + accepted: true, + childNodes, + childEdges, + childNodeIds, + proposedChildCount, + acceptedChildCount, + rejectedChildren, + childQualitySummary, + reason: + childNodes.length > 0 + ? "Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question." + : "Reused existing decomposition children for the composite unknown before asking the next question.", + }; +} + +function findNodeById(graph, nodeId) { + return (graph.nodes || []).find((node) => node.id === nodeId) || null; +} + +function runDeterministicDecomposition({ + graphSnapshot, + proposalSnapshot, + updatedSituationGraph, + reasoningResolution, + deterministicSelection, +}) { + let workingGraph = updatedSituationGraph; + let workingSelection = deterministicSelection; + let workingProposal = proposalSnapshot; + let nextReasoningState = workingGraph.reasoningState; + let lastAtomicityAssessment = null; + let rootAtomicityAssessment = null; + let decompositionDepth = 0; + let decompositionAttempted = false; + let decompositionAccepted = false; + let proposedChildCount = 0; + let acceptedChildCount = 0; + let selectedChildNodeId = null; + let decompositionStoppedReason = null; + let rejectedChildren = []; + let childQualitySummary = []; + + while (workingSelection?.status === "selected" && workingSelection?.nodeId) { + const selectedNode = findNodeById(workingGraph, workingSelection.nodeId); + if (!selectedNode) { + decompositionStoppedReason = + "Selected node was not present in the updated graph."; + break; + } + + const atomicityAssessment = assessUnknownAtomicity({ + node: selectedNode, + graph: workingGraph, + }); + lastAtomicityAssessment = atomicityAssessment; + if (!rootAtomicityAssessment) { + rootAtomicityAssessment = atomicityAssessment; + } + + if (atomicityAssessment.atomicity === "atomic") { + selectedChildNodeId = decompositionDepth > 0 ? selectedNode.id : null; + decompositionStoppedReason = + decompositionDepth > 0 + ? "Selected child is atomic and directly answerable." + : "Selected unknown is already atomic."; + break; + } + + if (hasExistingDecompositionChildren(workingGraph, selectedNode.id)) { + decompositionStoppedReason = + "Selected composite parent already has decomposition children, so they should be reused instead of regenerated."; + break; + } + + if (decompositionDepth >= MAX_DECOMPOSITION_DEPTH) { + decompositionStoppedReason = + "Maximum decomposition depth reached before finding a smaller atomic child."; + break; + } + + decompositionAttempted = true; + const decomposition = buildCompositeUnknownChildren( + selectedNode, + workingGraph, + decompositionDepth, + ); + + proposedChildCount = decomposition.proposedChildCount; + acceptedChildCount = decomposition.acceptedChildCount; + rejectedChildren = mergeUniqueRecords( + rejectedChildren, + decomposition.rejectedChildren, + ); + childQualitySummary = mergeUniqueRecords( + childQualitySummary, + decomposition.childQualitySummary, + ); + + if (!decomposition.accepted) { + decompositionStoppedReason = decomposition.reason; + break; + } + + const previousGraph = cloneJsonSafe(workingGraph); + const previousProposal = cloneJsonSafe(workingProposal); + const previousReasoningState = cloneJsonSafe(nextReasoningState); + + workingProposal.addedNodes.push(...decomposition.childNodes.map(cloneNode)); + workingProposal.addedEdges.push(...decomposition.childEdges.map(cloneNode)); + + const applied = applyGraphUpdate(graphSnapshot, workingProposal); + if (!applied.success) { + return { + success: false, + stage: "application", + errors: applied.errors, + }; + } + + workingGraph = { + ...graphSnapshot, + nodes: applied.nodes, + edges: applied.edges, + resolvedNodeIds: applied.resolvedNodeIds, + }; + nextReasoningState = buildReasoningState( + workingGraph, + reasoningResolution.reasoningStateOverride, + ); + workingGraph.reasoningState = nextReasoningState; + workingSelection = selectActiveUnknownCandidate( + workingGraph, + workingGraph.resolvedNodeIds, + ); + + if (workingSelection?.status !== "selected") { + workingGraph = previousGraph; + workingProposal = previousProposal; + nextReasoningState = previousReasoningState; + workingSelection = selectActiveUnknownCandidate( + workingGraph, + workingGraph.resolvedNodeIds, + ); + decompositionStoppedReason = + workingSelection?.status === "ambiguous" + ? "Decomposition produced multiple equally valid children with no justified distinction." + : "No unresolved child remained selectable after decomposition."; + break; + } + + decompositionAccepted = true; + decompositionDepth += 1; + } + + return { + success: true, + updatedSituationGraph: workingGraph, + proposalSnapshot: workingProposal, + reasoningState: nextReasoningState, + deterministicSelection: workingSelection, + atomicityAssessment: + rootAtomicityAssessment ?? lastAtomicityAssessment ?? null, + decompositionDepth, + decompositionAttempted, + decompositionAccepted, + decompositionStoppedReason, + proposedChildCount, + acceptedChildCount, + rejectedChildren, + childQualitySummary, + selectedChildNodeId, + }; +} + +function isComparabilityQuestion(question) { + const text = String(question || "").toLowerCase(); + return ( + text.includes("same basis") || + text.includes("same scale") || + text.includes("same period") + ); +} + +function answerConfirmsComparability(answer) { + const text = String(answer || "").toLowerCase(); + return ( + /\byes\b/.test(text) && + (text.includes("same accounting period") || + text.includes("same management accounts") || + text.includes("same basis") || + text.includes("same scale") || + text.includes("both figures cover the same")) + ); +} + +function deriveReasoningStateOverride({ + graph, + previousQuestion, + answer, + resolvedUnknownNodeIds, +}) { + const previousReasoningState = buildReasoningState(graph); + const previousComparabilityStatus = + previousReasoningState.comparabilityStatus ?? null; + + if ( + previousComparabilityStatus === "uncertain" && + isComparabilityQuestion(previousQuestion) && + answerConfirmsComparability(answer) + ) { + return { + reasoningStateOverride: { + comparabilityStatus: "confirmed", + comparabilityReason: + "Comparability was confirmed by the user answer covering the same period and source basis.", + comparabilityEvidence: resolvedUnknownNodeIds, + }, + resolvedReasoningNodeIds: [COMPARABILITY_REASONING_NODE_ID], + previousReasoningState, + }; + } + + return { + reasoningStateOverride: {}, + resolvedReasoningNodeIds: [], + previousReasoningState, + }; +} + +export function applyValidatedProposal({ + situationGraph, + proposal, + previousQuestion = null, + answer = null, +}) { const graphValidation = situationGraphSchema.safeParse(situationGraph); const proposalValidation = graphUpdateSchema.safeParse(proposal); @@ -571,8 +1910,44 @@ export function applyValidatedProposal({ situationGraph, proposal }) { const proposalSnapshot = cloneJsonSafe(validatedProposal); const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null; const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot); + const reasoningResolution = deriveReasoningStateOverride({ + graph: graphSnapshot, + previousQuestion, + answer, + resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, + }); - const applied = applyGraphUpdate(graphSnapshot, proposalSnapshot); + const provisionalApplied = applyGraphUpdate(graphSnapshot, proposalSnapshot); + if (!provisionalApplied.success) { + return { + success: false, + stage: "application", + errors: provisionalApplied.errors, + }; + } + const provisionalGraph = { + ...graphSnapshot, + nodes: provisionalApplied.nodes, + edges: provisionalApplied.edges, + resolvedNodeIds: provisionalApplied.resolvedNodeIds, + }; + provisionalGraph.reasoningState = buildReasoningState( + provisionalGraph, + reasoningResolution.reasoningStateOverride, + ); + const relationshipAssessment = + classifyObservationRelationship(provisionalGraph); + const emergentReasoningUnknown = buildEmergentReasoningUnknown( + provisionalGraph, + relationshipAssessment, + ); + + if (emergentReasoningUnknown?.created) { + proposalSnapshot.addedNodes.push(emergentReasoningUnknown.node); + proposalSnapshot.addedEdges.push(...emergentReasoningUnknown.edges); + } + + let applied = applyGraphUpdate(graphSnapshot, proposalSnapshot); if (!applied.success) { return { success: false, @@ -581,12 +1956,17 @@ export function applyValidatedProposal({ situationGraph, proposal }) { }; } - const updatedSituationGraph = { + let updatedSituationGraph = { ...graphSnapshot, nodes: applied.nodes, edges: applied.edges, resolvedNodeIds: applied.resolvedNodeIds, }; + let nextReasoningState = buildReasoningState( + updatedSituationGraph, + reasoningResolution.reasoningStateOverride, + ); + updatedSituationGraph.reasoningState = nextReasoningState; const activeUnknownWasResolved = previousActiveUnknownNodeId != null && @@ -618,23 +1998,114 @@ export function applyValidatedProposal({ situationGraph, proposal }) { )?.nodeId ?? null; } - const deterministicSelection = selectActiveUnknownCandidate( + let deterministicSelection = selectActiveUnknownCandidate( updatedSituationGraph, updatedSituationGraph.resolvedNodeIds, ); - if (deterministicSelection?.nodeId) { + const decompositionResult = runDeterministicDecomposition({ + graphSnapshot, + proposalSnapshot, + updatedSituationGraph, + reasoningResolution, + deterministicSelection, + }); + + if (!decompositionResult.success) { + return decompositionResult; + } + + updatedSituationGraph = decompositionResult.updatedSituationGraph; + nextReasoningState = decompositionResult.reasoningState; + deterministicSelection = decompositionResult.deterministicSelection; + + const propagationResult = propagateResolvedChildEvidence({ + updatedSituationGraph, + proposalSnapshot: decompositionResult.proposalSnapshot, + }); + + updatedSituationGraph = propagationResult.graph; + nextReasoningState = buildReasoningState( + updatedSituationGraph, + reasoningResolution.reasoningStateOverride, + ); + updatedSituationGraph.reasoningState = nextReasoningState; + deterministicSelection = selectActiveUnknownCandidate( + updatedSituationGraph, + updatedSituationGraph.resolvedNodeIds, + ); + + const atomicityAssessment = decompositionResult.atomicityAssessment; + const decompositionDepth = decompositionResult.decompositionDepth; + const decompositionAttempted = decompositionResult.decompositionAttempted; + const decompositionAccepted = decompositionResult.decompositionAccepted; + const decompositionStoppedReason = + decompositionResult.decompositionStoppedReason; + const proposedChildCount = decompositionResult.proposedChildCount; + const acceptedChildCount = decompositionResult.acceptedChildCount; + const rejectedChildren = decompositionResult.rejectedChildren; + const childQualitySummary = decompositionResult.childQualitySummary; + const selectedChildNodeId = decompositionResult.selectedChildNodeId; + const decompositionPerformed = + decompositionAttempted && decompositionAccepted; + const decompositionChildNodeIds = [ + ...new Set( + decompositionResult.proposalSnapshot.addedNodes + .filter((node) => node.kind === "unknown" && node.parentId != null) + .map((node) => node.id), + ), + ]; + const decompositionReason = decompositionStoppedReason; + const propagationPerformed = propagationResult.propagationPerformed; + const resolvedChildNodeId = propagationResult.resolvedChildNodeId; + const parentNodeId = propagationResult.parentNodeId; + const parentStatusBefore = propagationResult.parentStatusBefore; + const parentStatusAfter = propagationResult.parentStatusAfter; + const parentConfidenceBefore = propagationResult.parentConfidenceBefore; + const parentConfidenceAfter = propagationResult.parentConfidenceAfter; + const evidenceConfidenceBefore = propagationResult.evidenceConfidenceBefore; + const evidenceConfidenceAfter = propagationResult.evidenceConfidenceAfter; + const completenessBefore = propagationResult.completenessBefore; + const completenessAfter = propagationResult.completenessAfter; + const conclusionConfidenceBefore = + propagationResult.conclusionConfidenceBefore; + const conclusionConfidenceAfter = propagationResult.conclusionConfidenceAfter; + const resolvedDirectChildren = propagationResult.resolvedDirectChildren; + const unresolvedDirectChildren = propagationResult.unresolvedDirectChildren; + const contradictoryDirectChildren = + propagationResult.contradictoryDirectChildren; + const confidenceCapReason = propagationResult.confidenceCapReason; + const ancestorPropagationStoppedReason = + propagationResult.ancestorPropagationStoppedReason; + const affectedAncestorIds = propagationResult.affectedAncestorIds; + const nextSelectedSibling = propagationResult.nextSelectedSibling; + const parentResolved = propagationResult.parentResolved; + const corroboratingBranchCount = propagationResult.corroboratingBranchCount; + const conflictingBranchCount = propagationResult.conflictingBranchCount; + const duplicateEvidenceCount = propagationResult.duplicateEvidenceCount; + const independentBranchCount = propagationResult.independentBranchCount; + const interactionSummary = propagationResult.interactionSummary; + const propagationReason = propagationResult.reason; + + if ( + deterministicSelection?.status === "selected" && + deterministicSelection?.nodeId + ) { newActiveUnknownNodeId = deterministicSelection.nodeId; + } else if (deterministicSelection?.status === "ambiguous") { + newActiveUnknownNodeId = null; } updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId; updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph); - const selectedNode = deterministicSelection?.nodeId - ? updatedSituationGraph.nodes.find( - (node) => node.id === deterministicSelection.nodeId, - ) - : null; + const selectedNode = + deterministicSelection?.status === "selected" && + deterministicSelection?.nodeId + ? updatedSituationGraph.nodes.find( + (node) => node.id === deterministicSelection.nodeId, + ) + : null; const formulatedQuestion = selectedNode ? formulateQuestion({ node: selectedNode, @@ -645,19 +2116,29 @@ export function applyValidatedProposal({ situationGraph, proposal }) { .filter( (value) => typeof value === "string" && value.trim().length > 0, ), + selectionState: deterministicSelection, }, }) : null; - const finalSelectedQuestion = deterministicSelection - ? { - nodeId: deterministicSelection.nodeId, - question: - formulatedQuestion?.question || deterministicSelection.question, - reason: formulatedQuestion?.reason || deterministicSelection.reason, - strategy: formulatedQuestion?.strategy, - } - : null; + const finalSelectedQuestion = + deterministicSelection?.status === "ambiguous" + ? { + nodeId: null, + tiedCandidateIds: deterministicSelection.tiedCandidateIds, + question: null, + reason: deterministicSelection.reason, + } + : deterministicSelection?.status === "selected" + ? { + nodeId: deterministicSelection.nodeId, + question: + formulatedQuestion?.question || deterministicSelection.question, + reason: formulatedQuestion?.reason || deterministicSelection.reason, + strategy: formulatedQuestion?.strategy, + investigationStrategy: formulatedQuestion?.investigationStrategy, + } + : null; const resultGraphValidation = situationGraphSchema.safeParse( updatedSituationGraph, @@ -703,13 +2184,64 @@ export function applyValidatedProposal({ situationGraph, proposal }) { return { success: true, updatedSituationGraph, - graphUpdate: validatedProposal, + graphUpdate: proposalSnapshot, affectedNodeIds, - resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, + resolvedUnknownNodeIds: proposalSnapshot.resolvedUnknownNodeIds, + resolvedReasoningNodeIds: reasoningResolution.resolvedReasoningNodeIds, + emergentReasoningNodeCreated: Boolean(emergentReasoningUnknown?.created), + emergentReasoningNodeId: emergentReasoningUnknown?.node?.id ?? null, + emergentReasoningNodeReason: emergentReasoningUnknown?.reason ?? null, + atomicityAssessment: atomicityAssessment?.atomicity ?? null, + atomicityDecisionReason: atomicityAssessment?.reason ?? null, + decompositionDepth, + decompositionAttempted, + decompositionAccepted, + decompositionStoppedReason, + proposedChildCount, + acceptedChildCount, + rejectedChildren, + selectedChildNodeId, + childQualitySummary, + propagationPerformed, + resolvedChildNodeId, + parentNodeId, + parentStatusBefore, + parentStatusAfter, + parentConfidenceBefore, + parentConfidenceAfter, + evidenceConfidenceBefore, + evidenceConfidenceAfter, + completenessBefore, + completenessAfter, + conclusionConfidenceBefore, + conclusionConfidenceAfter, + resolvedDirectChildren, + unresolvedDirectChildren, + contradictoryDirectChildren, + corroboratingBranchCount, + conflictingBranchCount, + duplicateEvidenceCount, + independentBranchCount, + interactionSummary, + confidenceCapReason, + ancestorPropagationStoppedReason, + affectedAncestorIds, + nextSelectedSibling, + parentResolved, + decompositionPerformed, + childUnknownCount: decompositionChildNodeIds.length, + childNodeIds: decompositionChildNodeIds, + atomicityReason: + propagationReason || + decompositionReason || + atomicityAssessment?.reason || + null, previousActiveUnknownNodeId, newActiveUnknownNodeId, selectedQuestion: finalSelectedQuestion, - changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds), + changesApplied: buildChangesApplied(proposalSnapshot, affectedNodeIds), graphReferenceValidation: resultReferenceValidation, + previousReasoningState: reasoningResolution.previousReasoningState, + reasoningState: nextReasoningState, }; } diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index fc97ac8..d09ae7b 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -15,8 +15,13 @@ import { import { buildInitialGraph, describeGraph } from "./builder.js"; import { applyValidatedProposal } from "./apply-proposal.js"; import { buildGraphUpdatePrompt } from "./prompt-builder.js"; +import { + buildReasoningState, + formulateTieResolutionQuestion, +} from "./question-formulator.js"; import { parseGraphUpdateProposal } from "./update-proposal.js"; import { + explainUnknownSelection, selectActiveUnknownCandidate, validateGraphReferences, } from "./utils.js"; @@ -31,7 +36,12 @@ function toValidationErrors(error) { ); } -function buildDiagnostics({ analysis, graph, graphReferenceValidation }) { +function buildDiagnostics({ + analysis, + graph, + graphReferenceValidation, + unknownSelectionExplanation, +}) { return { promptVersion: analysis?.promptVersion ?? null, modelName: analysis?.modelName ?? null, @@ -43,9 +53,30 @@ function buildDiagnostics({ analysis, graph, graphReferenceValidation }) { compatibilityApplied: analysis?.compatibilityApplied ?? false, compatibilityChanges: analysis?.compatibilityChanges ?? [], compatibilityWarnings: analysis?.compatibilityWarnings ?? [], + unknownSelectionExplanation: unknownSelectionExplanation ?? null, }; } +function buildUnknownSelectionDiagnostics( + graph, + resolvedNodeIds = [], + selectedQuestion = null, +) { + const explanation = explainUnknownSelection(graph, resolvedNodeIds); + if (explanation.status === "ambiguous") { + return { + ...explanation, + tieResolutionQuestion: + selectedQuestion?.selectionStatus === "ambiguous" + ? selectedQuestion.question + : formulateTieResolutionQuestion({ graph }).question, + alphabeticalUsedAsReasoning: false, + }; + } + + return explanation; +} + function buildUpdateDiagnostics({ promptVersion, modelName, @@ -53,6 +84,55 @@ function buildUpdateDiagnostics({ normalisationsApplied, graph, graphReferenceValidation, + selectedQuestion, + unknownSelectionExplanation, + previousReasoningState, + reasoningState, + resolvedReasoningNodeIds, + emergentReasoningNodeCreated, + emergentReasoningNodeId, + emergentReasoningNodeReason, + atomicityAssessment, + atomicityDecisionReason, + decompositionDepth, + decompositionAttempted, + decompositionAccepted, + decompositionStoppedReason, + proposedChildCount, + acceptedChildCount, + rejectedChildren, + selectedChildNodeId, + childQualitySummary, + propagationPerformed, + resolvedChildNodeId, + parentNodeId, + parentStatusBefore, + parentStatusAfter, + parentConfidenceBefore, + parentConfidenceAfter, + evidenceConfidenceBefore, + evidenceConfidenceAfter, + completenessBefore, + completenessAfter, + conclusionConfidenceBefore, + conclusionConfidenceAfter, + resolvedDirectChildren, + unresolvedDirectChildren, + contradictoryDirectChildren, + corroboratingBranchCount, + conflictingBranchCount, + duplicateEvidenceCount, + independentBranchCount, + interactionSummary, + confidenceCapReason, + ancestorPropagationStoppedReason, + affectedAncestorIds, + nextSelectedSibling, + parentResolved, + decompositionPerformed, + childUnknownCount, + childNodeIds, + atomicityReason, }) { return { promptVersion: promptVersion ?? "v0.4", @@ -66,6 +146,63 @@ function buildUpdateDiagnostics({ errors: [], }, normalisationsApplied: normalisationsApplied ?? [], + investigationStrategy: + selectedQuestion?.investigationStrategy ?? + selectedQuestion?.strategy ?? + null, + previousComparabilityStatus: + previousReasoningState?.comparabilityStatus ?? null, + comparabilityStatus: reasoningState?.comparabilityStatus ?? null, + relationshipStatus: reasoningState?.relationshipStatus ?? null, + relationshipAssessed: reasoningState?.relationshipAssessed ?? null, + reasoningStagesBefore: previousReasoningState?.reasoningStages ?? [], + reasoningStagesAfter: reasoningState?.reasoningStages ?? [], + resolvedReasoningNodeIds: resolvedReasoningNodeIds ?? [], + emergentReasoningNodeCreated: emergentReasoningNodeCreated ?? false, + emergentReasoningNodeId: emergentReasoningNodeId ?? null, + emergentReasoningNodeReason: emergentReasoningNodeReason ?? null, + atomicityAssessment: atomicityAssessment ?? null, + atomicityDecisionReason: atomicityDecisionReason ?? null, + decompositionDepth: decompositionDepth ?? 0, + decompositionAttempted: decompositionAttempted ?? false, + decompositionAccepted: decompositionAccepted ?? false, + decompositionStoppedReason: decompositionStoppedReason ?? null, + proposedChildCount: proposedChildCount ?? 0, + acceptedChildCount: acceptedChildCount ?? 0, + rejectedChildren: rejectedChildren ?? [], + selectedChildNodeId: selectedChildNodeId ?? null, + childQualitySummary: childQualitySummary ?? [], + propagationPerformed: propagationPerformed ?? false, + resolvedChildNodeId: resolvedChildNodeId ?? null, + parentNodeId: parentNodeId ?? null, + parentStatusBefore: parentStatusBefore ?? null, + parentStatusAfter: parentStatusAfter ?? null, + parentConfidenceBefore: parentConfidenceBefore ?? null, + parentConfidenceAfter: parentConfidenceAfter ?? null, + evidenceConfidenceBefore: evidenceConfidenceBefore ?? null, + evidenceConfidenceAfter: evidenceConfidenceAfter ?? null, + completenessBefore: completenessBefore ?? null, + completenessAfter: completenessAfter ?? null, + conclusionConfidenceBefore: conclusionConfidenceBefore ?? null, + conclusionConfidenceAfter: conclusionConfidenceAfter ?? null, + resolvedDirectChildren: resolvedDirectChildren ?? 0, + unresolvedDirectChildren: unresolvedDirectChildren ?? 0, + contradictoryDirectChildren: contradictoryDirectChildren ?? 0, + corroboratingBranchCount: corroboratingBranchCount ?? 0, + conflictingBranchCount: conflictingBranchCount ?? 0, + duplicateEvidenceCount: duplicateEvidenceCount ?? 0, + independentBranchCount: independentBranchCount ?? 0, + interactionSummary: interactionSummary ?? null, + confidenceCapReason: confidenceCapReason ?? null, + ancestorPropagationStoppedReason: ancestorPropagationStoppedReason ?? null, + affectedAncestorIds: affectedAncestorIds ?? [], + nextSelectedSibling: nextSelectedSibling ?? null, + parentResolved: parentResolved ?? false, + decompositionPerformed: decompositionPerformed ?? false, + childUnknownCount: childUnknownCount ?? 0, + childNodeIds: childNodeIds ?? [], + atomicityReason: atomicityReason ?? null, + unknownSelectionExplanation: unknownSelectionExplanation ?? null, }; } @@ -105,14 +242,17 @@ export async function startCase(body) { }); const currentSummary = describeGraph(initialGraph); + const deterministicSelection = selectActiveUnknownCandidate( + { + ...initialGraph, + resolvedNodeIds: [], + }, + [], + ); const activeUnknownNodeId = - selectActiveUnknownCandidate( - { - ...initialGraph, - resolvedNodeIds: [], - }, - [], - )?.nodeId ?? null; + deterministicSelection?.status === "selected" + ? deterministicSelection.nodeId + : null; const situationGraph = makeGraph({ centralStatement: scenario, @@ -121,11 +261,30 @@ export async function startCase(body) { activeUnknownNodeId, resolvedNodeIds: [], currentSummary, + reasoningState: buildReasoningState({ + centralStatement: scenario, + nodes: initialGraph.nodes, + edges: initialGraph.edges, + resolvedNodeIds: [], + }), }); situationGraphSchema.parse(situationGraph); const graphReferenceValidation = validateGraphReferences(situationGraph); + const selectedQuestion = + deterministicSelection?.status === "ambiguous" + ? { + id: "q_tie_resolution", + ...formulateTieResolutionQuestion({ graph: situationGraph }), + tiedCandidateIds: deterministicSelection.tiedCandidateIds, + } + : (analysis.nextQuestion ?? null); + const unknownSelectionExplanation = buildUnknownSelectionDiagnostics( + situationGraph, + [], + selectedQuestion, + ); if (!graphReferenceValidation.valid) { return { success: false, @@ -134,6 +293,7 @@ export async function startCase(body) { analysis, graph: situationGraph, graphReferenceValidation, + unknownSelectionExplanation, }), validationErrors: graphReferenceValidation.errors, statusCode: 500, @@ -143,11 +303,12 @@ export async function startCase(body) { return { success: true, situationGraph, - selectedQuestion: analysis.nextQuestion ?? null, + selectedQuestion, diagnostics: buildDiagnostics({ analysis, graph: situationGraph, graphReferenceValidation, + unknownSelectionExplanation, }), }; } @@ -269,6 +430,8 @@ async function updateCaseWithDependencies(body, dependencies = {}) { const applicationResult = applyProposalUpdate({ situationGraph, proposal: parsedProposal.proposal, + previousQuestion, + answer, }); if (!applicationResult.success) { @@ -284,6 +447,58 @@ async function updateCaseWithDependencies(body, dependencies = {}) { normalisationsApplied: parsedProposal.normalisationsApplied, graph: situationGraph, graphReferenceValidation: graphReferenceValidation, + selectedQuestion: null, + previousReasoningState: buildReasoningState(situationGraph), + reasoningState: buildReasoningState(situationGraph), + resolvedReasoningNodeIds: [], + emergentReasoningNodeCreated: false, + emergentReasoningNodeId: null, + emergentReasoningNodeReason: null, + atomicityAssessment: null, + atomicityDecisionReason: null, + decompositionDepth: 0, + decompositionAttempted: false, + decompositionAccepted: false, + decompositionStoppedReason: null, + proposedChildCount: 0, + acceptedChildCount: 0, + rejectedChildren: [], + selectedChildNodeId: null, + childQualitySummary: [], + propagationPerformed: false, + resolvedChildNodeId: null, + parentNodeId: null, + parentStatusBefore: null, + parentStatusAfter: null, + parentConfidenceBefore: null, + parentConfidenceAfter: null, + evidenceConfidenceBefore: null, + evidenceConfidenceAfter: null, + completenessBefore: null, + completenessAfter: null, + conclusionConfidenceBefore: null, + conclusionConfidenceAfter: null, + resolvedDirectChildren: 0, + unresolvedDirectChildren: 0, + contradictoryDirectChildren: 0, + corroboratingBranchCount: 0, + conflictingBranchCount: 0, + duplicateEvidenceCount: 0, + independentBranchCount: 0, + interactionSummary: null, + confidenceCapReason: null, + ancestorPropagationStoppedReason: null, + affectedAncestorIds: [], + nextSelectedSibling: null, + parentResolved: false, + decompositionPerformed: false, + childUnknownCount: 0, + childNodeIds: [], + atomicityReason: null, + unknownSelectionExplanation: explainUnknownSelection( + situationGraph, + situationGraph.resolvedNodeIds || [], + ), }), }, statusCode: @@ -313,6 +528,65 @@ async function updateCaseWithDependencies(body, dependencies = {}) { normalisationsApplied: parsedProposal.normalisationsApplied, graph: applicationResult.updatedSituationGraph, graphReferenceValidation: applicationResult.graphReferenceValidation, + selectedQuestion: applicationResult.selectedQuestion, + previousReasoningState: applicationResult.previousReasoningState, + reasoningState: applicationResult.reasoningState, + resolvedReasoningNodeIds: applicationResult.resolvedReasoningNodeIds, + emergentReasoningNodeCreated: + applicationResult.emergentReasoningNodeCreated, + emergentReasoningNodeId: applicationResult.emergentReasoningNodeId, + emergentReasoningNodeReason: + applicationResult.emergentReasoningNodeReason, + atomicityAssessment: applicationResult.atomicityAssessment, + atomicityDecisionReason: applicationResult.atomicityDecisionReason, + decompositionDepth: applicationResult.decompositionDepth, + decompositionAttempted: applicationResult.decompositionAttempted, + decompositionAccepted: applicationResult.decompositionAccepted, + decompositionStoppedReason: + applicationResult.decompositionStoppedReason, + proposedChildCount: applicationResult.proposedChildCount, + acceptedChildCount: applicationResult.acceptedChildCount, + rejectedChildren: applicationResult.rejectedChildren, + selectedChildNodeId: applicationResult.selectedChildNodeId, + childQualitySummary: applicationResult.childQualitySummary, + propagationPerformed: applicationResult.propagationPerformed, + resolvedChildNodeId: applicationResult.resolvedChildNodeId, + parentNodeId: applicationResult.parentNodeId, + parentStatusBefore: applicationResult.parentStatusBefore, + parentStatusAfter: applicationResult.parentStatusAfter, + parentConfidenceBefore: applicationResult.parentConfidenceBefore, + parentConfidenceAfter: applicationResult.parentConfidenceAfter, + evidenceConfidenceBefore: applicationResult.evidenceConfidenceBefore, + evidenceConfidenceAfter: applicationResult.evidenceConfidenceAfter, + completenessBefore: applicationResult.completenessBefore, + completenessAfter: applicationResult.completenessAfter, + conclusionConfidenceBefore: + applicationResult.conclusionConfidenceBefore, + conclusionConfidenceAfter: applicationResult.conclusionConfidenceAfter, + resolvedDirectChildren: applicationResult.resolvedDirectChildren, + unresolvedDirectChildren: applicationResult.unresolvedDirectChildren, + contradictoryDirectChildren: + applicationResult.contradictoryDirectChildren, + corroboratingBranchCount: applicationResult.corroboratingBranchCount, + conflictingBranchCount: applicationResult.conflictingBranchCount, + duplicateEvidenceCount: applicationResult.duplicateEvidenceCount, + independentBranchCount: applicationResult.independentBranchCount, + interactionSummary: applicationResult.interactionSummary, + confidenceCapReason: applicationResult.confidenceCapReason, + ancestorPropagationStoppedReason: + applicationResult.ancestorPropagationStoppedReason, + affectedAncestorIds: applicationResult.affectedAncestorIds, + nextSelectedSibling: applicationResult.nextSelectedSibling, + parentResolved: applicationResult.parentResolved, + decompositionPerformed: applicationResult.decompositionPerformed, + childUnknownCount: applicationResult.childUnknownCount, + childNodeIds: applicationResult.childNodeIds, + atomicityReason: applicationResult.atomicityReason, + unknownSelectionExplanation: buildUnknownSelectionDiagnostics( + applicationResult.updatedSituationGraph, + applicationResult.updatedSituationGraph.resolvedNodeIds || [], + applicationResult.selectedQuestion, + ), }), }; } @@ -328,6 +602,59 @@ async function updateCaseWithDependencies(body, dependencies = {}) { normalisationsApplied: parsedProposal.normalisationsApplied, graph: situationGraph, graphReferenceValidation, + selectedQuestion: null, + previousReasoningState: buildReasoningState(situationGraph), + reasoningState: buildReasoningState(situationGraph), + resolvedReasoningNodeIds: [], + emergentReasoningNodeCreated: false, + emergentReasoningNodeId: null, + emergentReasoningNodeReason: null, + atomicityAssessment: null, + atomicityDecisionReason: null, + decompositionDepth: 0, + decompositionAttempted: false, + decompositionAccepted: false, + decompositionStoppedReason: null, + proposedChildCount: 0, + acceptedChildCount: 0, + rejectedChildren: [], + selectedChildNodeId: null, + childQualitySummary: [], + propagationPerformed: false, + resolvedChildNodeId: null, + parentNodeId: null, + parentStatusBefore: null, + parentStatusAfter: null, + parentConfidenceBefore: null, + parentConfidenceAfter: null, + evidenceConfidenceBefore: null, + evidenceConfidenceAfter: null, + completenessBefore: null, + completenessAfter: null, + conclusionConfidenceBefore: null, + conclusionConfidenceAfter: null, + resolvedDirectChildren: 0, + unresolvedDirectChildren: 0, + contradictoryDirectChildren: 0, + corroboratingBranchCount: 0, + conflictingBranchCount: 0, + duplicateEvidenceCount: 0, + independentBranchCount: 0, + interactionSummary: null, + confidenceCapReason: null, + ancestorPropagationStoppedReason: null, + affectedAncestorIds: [], + nextSelectedSibling: null, + parentResolved: false, + decompositionPerformed: false, + childUnknownCount: 0, + childNodeIds: [], + atomicityReason: null, + unknownSelectionExplanation: buildUnknownSelectionDiagnostics( + situationGraph, + situationGraph.resolvedNodeIds || [], + null, + ), }), }; } diff --git a/lib/graph/question-formulator.js b/lib/graph/question-formulator.js index da164ae..811c873 100644 --- a/lib/graph/question-formulator.js +++ b/lib/graph/question-formulator.js @@ -11,6 +11,13 @@ function sentenceCase(value) { return trimmed.charAt(0).toLowerCase() + trimmed.slice(1); } +function stripTrailingPunctuation(value) { + return String(value || "") + .trim() + .replace(/[.?!:;]+$/g, "") + .trim(); +} + function buildNodeMap(graph) { return new Map((graph?.nodes || []).map((node) => [node.id, node])); } @@ -52,8 +59,8 @@ function collectResolvedContextValues(graph) { function extractMeaning(node) { const raw = `${node?.label || ""} ${node?.description || ""}`.trim(); - let meaning = String( - node?.label || node?.description || "this uncertainty", + let meaning = stripTrailingPunctuation( + String(node?.label || node?.description || "this uncertainty"), ).trim(); const lowered = normaliseText(raw); @@ -79,6 +86,608 @@ function extractMeaning(node) { return sentenceCase(meaning); } +function isDefinitionLikeUnknown(nodeText, text) { + return ( + /\b(define|definition|meaning|term|terminology)\b/.test(nodeText) || + (/\bdefinition\b/.test(text) && /\bdisagreement\b/.test(text)) || + (/\b(define|definition|meaning|term|terminology)\b/.test(text) && + /\b(unclear|ambiguous|inconsistent|undefined|used inconsistently)\b/.test( + text, + )) + ); +} + +function isClaimLikeUnknown(node, text) { + return ( + node?.kind === "reported_claim" || + node?.kind === "conclusion" || + /\b(claim|assertion|true|false|correct|incorrect|happened|happening)\b/.test( + text, + ) || + /^whether\b/i.test(String(node?.label || "").trim()) + ); +} + +function sanitizeQuestionText(question) { + return String(question || "") + .replace(/\)\.\s+/g, ") ") + .replace(/\s+/g, " ") + .trim(); +} + +function buildNeutralClarificationQuestion(meaning) { + return `What would clarify ${stripTrailingPunctuation(meaning)} in this situation?`; +} + +function buildEvidenceFallbackQuestion(meaning) { + return `What evidence would confirm or rule out ${stripTrailingPunctuation(meaning)}?`; +} + +function collectObservationNodes(graph) { + return (graph?.nodes || []).filter( + (node) => node.kind === "observation" && node.status === "supported", + ); +} + +function analyseObservationText(text) { + const normalised = normaliseText(text); + return { + text, + normalised, + isMeasurementLike: + /\b(increase|increased|decrease|decreased|fell|rose|doubled|halved|remained|average|score|scores|rate|time|traffic|sales|output|defects|complaints|production|revenue|cash|temperature|quality)\b/.test( + normalised, + ) || /%|percent/.test(String(text || "")), + timeframeMentioned: + /\b(period|timeframe|quarter|month|week|year|day|annual|daily|weekly|monthly|same period)\b/.test( + normalised, + ), + scaleMentioned: /\b(average|rate|score|scores|per|percent|%)\b/.test( + normalised, + ), + unitMentioned: + /\b(celsius|fahrenheit|minutes|minute|hours|hour|days|day|units|sales|traffic|cash|revenue|complaints|defects)\b/.test( + normalised, + ), + }; +} + +export const COMPARABILITY_REASONING_NODE_ID = "reasoning:comparability"; + +function readStoredComparabilityState(graph) { + const reasoningState = graph?.reasoningState; + if (!reasoningState?.comparabilityStatus) return null; + + return { + comparabilityStatus: reasoningState.comparabilityStatus, + reason: + reasoningState.comparabilityReason || + "Comparability state was carried forward from earlier reasoning.", + contradictionReasoningAllowed: + reasoningState.comparabilityStatus === "confirmed", + }; +} + +export function assessComparability(graph) { + const storedState = readStoredComparabilityState(graph); + if (storedState) { + return storedState; + } + + const observations = collectObservationNodes(graph); + const centralText = normaliseText(graph?.centralStatement || ""); + const profiles = observations.map((node) => + analyseObservationText(`${node.label} ${node.description}`), + ); + + if (profiles.length < 2) { + return { + comparabilityStatus: "confirmed", + reason: "Fewer than two supported observations need comparison.", + contradictionReasoningAllowed: true, + }; + } + + if ( + profiles.every((profile) => profile.normalised === profiles[0].normalised) + ) { + return { + comparabilityStatus: "confirmed", + reason: "The observations restate the same measurement.", + contradictionReasoningAllowed: false, + }; + } + + if (profiles.some((profile) => !profile.isMeasurementLike)) { + return { + comparabilityStatus: "confirmed", + reason: "The observations are not competing like-for-like measurements.", + contradictionReasoningAllowed: true, + }; + } + + const hasExplicitTimeframe = + /\b(period|timeframe|quarter|month|week|year|day|same period)\b/.test( + centralText, + ) || profiles.every((profile) => profile.timeframeMentioned); + + const hasSharedScale = profiles.every((profile) => profile.scaleMentioned); + const hasSharedUnits = profiles.every((profile) => profile.unitMentioned); + + if (!hasExplicitTimeframe || !hasSharedScale || !hasSharedUnits) { + return { + comparabilityStatus: "uncertain", + reason: + "Comparability between the observations is not yet established across period, scale, or measurement basis.", + contradictionReasoningAllowed: false, + }; + } + + return { + comparabilityStatus: "uncertain", + reason: + "The observations appear comparable in form, but the basis for comparing them is still not established.", + contradictionReasoningAllowed: false, + }; +} + +function buildReasoningStages(comparability, relationship, deferred = false) { + return [ + { + stage: "comparability", + status: comparability.comparabilityStatus, + outcome: comparability.reason, + }, + { + stage: "relationship", + status: relationship.relationshipStatus, + outcome: deferred + ? "not assessed until comparability is established" + : relationship.reason, + }, + ]; +} + +function extractObservationConcepts(profile) { + const concepts = new Set(); + const text = profile.normalised; + const conceptPatterns = [ + ["sales", /\bsales\b/], + ["revenue", /\brevenue\b/], + ["cash", /\bcash\b/], + ["complaints", /\bcomplaints?\b/], + ["production", /\bproduction\b/], + ["delivery_time", /\bdelivery time\b|\baverage delivery time\b/], + ["cancellations", /\bcancellations?\b/], + ["satisfaction", /\bsatisfaction\b/], + ["temperature", /\btemperature\b/], + ["ice", /\bice\b/], + ["traffic", /\btraffic\b/], + ["defects", /\bdefects?\b/], + ["quality", /\bquality\b/], + ["staffing", /\bstaff(ing)?\b/], + ["availability", /\bavailable|availability|unavailable\b/], + ["service", /\bservice\b/], + ]; + + for (const [name, pattern] of conceptPatterns) { + if (pattern.test(text)) concepts.add(name); + } + + return [...concepts]; +} + +function extractObservationDirection(profile) { + const text = profile.normalised; + if (/\bunavailable\b/.test(text)) return "unavailable"; + if (/\b(increase|increased|rose|up|doubled)\b/.test(text)) return "up"; + if (/\b(decrease|decreased|fell|down|halved)\b/.test(text)) return "down"; + if (/\b(remained unchanged|unchanged|same)\b/.test(text)) return "flat"; + if (/\bavailable\b/.test(text)) return "available"; + if (/\bmelted\b/.test(text)) return "melted"; + return "unknown"; +} + +function classifyObservationRelationshipWhenComparable(graph) { + const observations = collectObservationNodes(graph); + const profiles = observations.map((node) => + analyseObservationText(`${node.label} ${node.description}`), + ); + + if (profiles.length < 2) { + return { + relationshipStatus: "insufficient_information", + reason: + "Fewer than two supported observations are available for comparison.", + contradictionReasoningAllowed: false, + questionRequired: false, + questionSuppressedReason: + "Not enough observations to classify a relationship.", + }; + } + + if ( + profiles.every((profile) => profile.normalised === profiles[0].normalised) + ) { + return { + relationshipStatus: "duplicate", + reason: "The observations repeat the same measurement and direction.", + contradictionReasoningAllowed: false, + questionRequired: false, + questionSuppressedReason: + "Duplicate observations do not justify a follow-up question.", + }; + } + + const conceptSets = profiles.map((profile) => + extractObservationConcepts(profile), + ); + const sharedConcepts = conceptSets.reduce((shared, concepts, index) => { + if (index === 0) return new Set(concepts); + return new Set(concepts.filter((concept) => shared.has(concept))); + }, new Set()); + const directions = profiles.map((profile) => + extractObservationDirection(profile), + ); + const conceptUnion = new Set(conceptSets.flat()); + const hasRevenueCashPair = + conceptUnion.has("revenue") && conceptUnion.has("cash"); + + if ( + sharedConcepts.size > 0 && + directions.includes("available") && + directions.includes("unavailable") + ) { + return { + relationshipStatus: "contradictory", + reason: + "The observations assert mutually incompatible states about the same subject.", + contradictionReasoningAllowed: true, + questionRequired: true, + }; + } + + if ( + sharedConcepts.size > 0 && + directions.every((direction) => direction !== "unknown") + ) { + return { + relationshipStatus: "potentially_related", + reason: + "The observations concern the same subject but do not assert a direct contradiction.", + contradictionReasoningAllowed: false, + questionRequired: true, + }; + } + + if ( + hasRevenueCashPair && + directions.every((direction) => direction !== "unknown") + ) { + return { + relationshipStatus: "potentially_related", + reason: + "The observations concern connected business signals but do not establish a direct contradiction or cause.", + contradictionReasoningAllowed: false, + questionRequired: true, + }; + } + + if ( + sharedConcepts.size === 0 && + directions.every((direction) => direction !== "unknown") + ) { + return { + relationshipStatus: "compatible", + reason: + "The observations can coexist without asserting incompatible states about the same subject.", + contradictionReasoningAllowed: false, + questionRequired: false, + questionSuppressedReason: + "Compatible observations do not justify a contradiction investigation.", + }; + } + + return { + relationshipStatus: "insufficient_information", + reason: + "There is not enough structure to classify the relationship safely.", + contradictionReasoningAllowed: false, + questionRequired: true, + }; +} + +export function classifyObservationRelationship(graph) { + const comparability = assessComparability(graph); + + if (comparability.comparabilityStatus !== "confirmed") { + const deferredRelationship = { + relationshipStatus: "insufficient_information", + reason: + "Relationship classification is deferred until comparability is established.", + contradictionReasoningAllowed: false, + questionRequired: comparability.comparabilityStatus === "uncertain", + questionSuppressedReason: + comparability.comparabilityStatus === "incompatible" + ? "Relationship classification was not attempted because the observations are not yet comparable." + : undefined, + relationshipAssessed: false, + }; + + return { + ...deferredRelationship, + reasoningStages: buildReasoningStages( + comparability, + deferredRelationship, + true, + ), + }; + } + + const classified = classifyObservationRelationshipWhenComparable(graph); + + return { + ...classified, + relationshipAssessed: true, + reasoningStages: buildReasoningStages(comparability, classified, false), + }; +} + +export function buildReasoningState(graph, overrides = {}) { + const relationship = classifyObservationRelationship({ + ...graph, + reasoningState: { + ...(graph?.reasoningState || {}), + ...(overrides || {}), + }, + }); + + return { + comparabilityStatus: relationship.reasoningStages[0]?.status ?? null, + comparabilityReason: relationship.reasoningStages[0]?.outcome ?? null, + comparabilityEvidence: + overrides.comparabilityEvidence ?? + graph?.reasoningState?.comparabilityEvidence ?? + [], + relationshipStatus: relationship.relationshipStatus, + relationshipReason: relationship.reason, + relationshipAssessed: relationship.relationshipAssessed, + contradictionReasoningAllowed: relationship.contradictionReasoningAllowed, + reasoningStages: relationship.reasoningStages, + }; +} + +function buildComparabilityQuestion(graph, assessment) { + const centralText = normaliseText(graph?.centralStatement || ""); + const mentionsPeriod = + /\b(period|timeframe|quarter|month|week|year|day)\b/.test(centralText); + + if (mentionsPeriod) { + return "Were these figures measured on the same basis and at the same scale?"; + } + + return "Were these figures measured over the same period and at the same scale?"; +} + +function detectContradictionContext(graph) { + const central = stripTrailingPunctuation( + graph?.centralStatement || "this situation", + ); + const contradictionNode = (graph?.nodes || []).find((node) => { + const text = normaliseText(`${node.label} ${node.description}`); + return ( + node.kind === "relationship" && + /\b(contradiction|conflict|inconsistent|mismatch|divergent|opposing)\b/.test( + text, + ) + ); + }); + + return { + centralStatement: central, + contradictionLabel: stripTrailingPunctuation( + contradictionNode?.label || "", + ), + }; +} + +function buildBroadInvestigationQuestion(graph) { + const central = sanitizeQuestionText( + stripTrailingPunctuation(graph?.centralStatement || "these observations"), + ); + return `What changed during that period that could help explain why ${central}?`; +} + +function isRelationshipExplanationUnknown(node, graph) { + const text = normaliseText(`${node?.label || ""} ${node?.description || ""}`); + return ( + collectObservationNodes(graph).length >= 2 && + /\b(explain|explanation|divergence|moved differently|difference between|change or event|what changed|why the observations)/.test( + text, + ) + ); +} + +function isBroadCompositeUnknownText(text) { + return /\b(possible causes|possible reasons|root causes|causes of|drivers of|factors behind|factors affecting|what changed|explanation for why|why .* but|difference between|divergence|moved differently|broad explanation|independent dimensions)\b/.test( + text, + ); +} + +function hasCompoundAbstractSignals(text) { + return ( + /\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital spending|mix|segment)\s+(and|or)\s+\b/.test( + text, + ) || + /\b[a-z]+\/[a-z]+\b/.test(text) || + /,\s*[a-z]+,\s*[a-z]+/.test(text) + ); +} + +function isFocusedAtomicUnknownText(text) { + return /\b(define|definition|meaning|term|threshold|criterion|criteria|baseline|evidence|measure|metric|denominator|rate|date|period|budget|constraint|customer|actor|owner)\b/.test( + text, + ); +} + +function isDirectlyAnswerableObservationChildText(text) { + return /\b(whether the two observations reflect different timing|how the two observations were measured|change mainly affecting|one off event during the period|mix shift during the period)\b/.test( + text, + ); +} + +export function assessUnknownAtomicity({ node, graph }) { + const nodeText = normaliseText( + `${node?.label || ""} ${node?.description || ""}`, + ); + + if ( + isDirectlyAnswerableObservationChildText(nodeText) && + !hasCompoundAbstractSignals(nodeText) + ) { + return { + atomicity: "atomic", + reason: + "This unknown isolates one specific line of enquiry and can be investigated directly.", + decompositionKind: null, + }; + } + + if (isRelationshipExplanationUnknown(node, graph)) { + return { + atomicity: "composite", + reason: + "This unknown asks for a broad explanation across multiple observations, so it should be decomposed before asking a direct question.", + decompositionKind: "relationship_explanation", + }; + } + + if (hasCompoundAbstractSignals(nodeText)) { + return { + atomicity: "composite", + reason: + "This unknown still bundles multiple abstract uncertainties together, so it should be decomposed before asking it directly.", + decompositionKind: "compound_child", + }; + } + + if ( + isFocusedAtomicUnknownText(nodeText) && + !isBroadCompositeUnknownText(nodeText) + ) { + return { + atomicity: "atomic", + reason: + "This unknown already targets a single concrete detail that can be investigated directly.", + decompositionKind: null, + }; + } + + if (isBroadCompositeUnknownText(nodeText)) { + return { + atomicity: "composite", + reason: + "This unknown combines multiple broad candidate explanations, so it should be split into smaller dimensions first.", + decompositionKind: "broad_explanation", + }; + } + + return { + atomicity: "atomic", + reason: + "No deterministic composite pattern was detected, so the unknown can be investigated directly.", + decompositionKind: null, + }; +} + +export function formulateTieResolutionQuestion({ graph }) { + const comparability = assessComparability(graph); + if (comparability.comparabilityStatus === "uncertain") { + const deferredRelationship = classifyObservationRelationship(graph); + return { + question: buildComparabilityQuestion(graph, comparability), + reason: + "Formulated to confirm whether the observations are comparable before exploring competing explanations.", + strategy: null, + investigationStrategy: null, + selectionStatus: "ambiguous", + comparabilityStatus: comparability.comparabilityStatus, + comparabilityReason: comparability.reason, + contradictionReasoningAllowed: + comparability.contradictionReasoningAllowed, + relationshipStatus: deferredRelationship.relationshipStatus, + relationshipReason: deferredRelationship.reason, + relationshipAssessed: deferredRelationship.relationshipAssessed, + questionRequired: true, + reasoningStages: deferredRelationship.reasoningStages, + }; + } + + const relationship = classifyObservationRelationship(graph); + if (!relationship.questionRequired) { + return { + question: null, + reason: relationship.reason, + strategy: null, + investigationStrategy: null, + selectionStatus: "ambiguous", + comparabilityStatus: comparability.comparabilityStatus, + comparabilityReason: comparability.reason, + relationshipStatus: relationship.relationshipStatus, + relationshipReason: relationship.reason, + relationshipAssessed: relationship.relationshipAssessed, + contradictionReasoningAllowed: relationship.contradictionReasoningAllowed, + questionRequired: relationship.questionRequired, + questionSuppressedReason: relationship.questionSuppressedReason, + reasoningStages: relationship.reasoningStages, + }; + } + + if (relationship.relationshipStatus === "potentially_related") { + return { + question: buildBroadInvestigationQuestion(graph), + reason: + "Formulated as a neutral relationship question because the observations may be related without being contradictory.", + strategy: null, + investigationStrategy: null, + selectionStatus: "ambiguous", + comparabilityStatus: comparability.comparabilityStatus, + comparabilityReason: comparability.reason, + relationshipStatus: relationship.relationshipStatus, + relationshipReason: relationship.reason, + relationshipAssessed: relationship.relationshipAssessed, + contradictionReasoningAllowed: relationship.contradictionReasoningAllowed, + questionRequired: relationship.questionRequired, + reasoningStages: relationship.reasoningStages, + }; + } + + const { centralStatement, contradictionLabel } = + detectContradictionContext(graph); + const focus = + centralStatement || contradictionLabel || "these conflicting signals"; + const question = sanitizeQuestionText( + `What changed during the period that could explain why ${focus}?`, + ); + + return { + question, + reason: + "Formulated to distinguish between tied unresolved explanations without prematurely choosing one branch.", + strategy: null, + investigationStrategy: null, + selectionStatus: "ambiguous", + comparabilityStatus: comparability.comparabilityStatus, + comparabilityReason: comparability.reason, + relationshipStatus: relationship.relationshipStatus, + relationshipReason: relationship.reason, + relationshipAssessed: relationship.relationshipAssessed, + contradictionReasoningAllowed: relationship.contradictionReasoningAllowed, + questionRequired: relationship.questionRequired, + reasoningStages: relationship.reasoningStages, + }; +} + function extractActionPhrase(texts) { for (const text of texts) { const value = String(text || "").trim(); @@ -139,7 +748,41 @@ function toGerundPhrase(phrase) { return [gerund, ...rest].join(" "); } -function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) { +function buildInvestigationStrategy({ + key, + reason, + node, + graph, + relatedNodes, + meaning, + actionPhrase, +}) { + return { + key, + reason, + nodeId: node?.id ?? null, + nodeLabel: node?.label ?? null, + meaning, + actionPhrase, + relatedNodeIds: relatedNodes.map((relatedNode) => relatedNode.id), + centralStatement: graph?.centralStatement ?? null, + }; +} + +export function selectInvestigationStrategy({ node, graph, context = {} }) { + const relatedNodes = collectRelatedNodes(node, graph); + const meaning = extractMeaning(node); + const combinedText = [ + node?.label, + node?.description, + ...relatedNodes.map((relatedNode) => relatedNode.label), + ...relatedNodes.map((relatedNode) => relatedNode.description), + graph?.centralStatement, + ...(context.resolvedValues || []), + ] + .filter(Boolean) + .join(" "); + const text = normaliseText(combinedText); const nodeText = normaliseText( `${node?.label || ""} ${node?.description || ""}`, @@ -172,25 +815,25 @@ function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) { nodeText, ); - if (/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(text)) { - return { strategy: "actor/customer", meaning, actionPhrase }; - } - const hasBaselineLanguage = /\b(before|previous|baseline|prior|comparable state)\b/.test(text); const hasPrimaryBaselineLanguage = /\b(before|previous|baseline|prior|comparable state)\b/.test(nodeText); if (hasBaselineLanguage && hasPrimaryBaselineLanguage) { - return { strategy: "baseline", meaning, actionPhrase }; + return buildInvestigationStrategy({ + key: "baseline_reconstruction", + reason: + "Selected because the unknown explicitly references a missing previous or baseline state.", + node, + graph, + relatedNodes, + meaning, + actionPhrase, + }); } - if (/\b(when|timing|timeline|duration|sequence|milestone)\b/.test(text)) { - return { strategy: "transition/timing", meaning, actionPhrase }; - } - - const hasDefinitionLanguage = - /\b(define|definition|meaning|term|terminology)\b/.test(text); + const hasDefinitionLanguage = isDefinitionLikeUnknown(nodeText, text); const hasPrimaryDefinitionLanguage = /\b(define|definition|meaning|term|terminology)\b/.test(nodeText); const hasCriteriaLanguage = @@ -206,84 +849,105 @@ function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) { /\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test( text, ); + const hasEvidenceLanguage = + /\b(evidence|proof|validate|validation|signal|demand)\b/.test(text) || + isClaimLikeUnknown(node, text); + const hasContradictionLanguage = + /\b(contradiction|contradict|conflict|inconsistent|inconsistency|disagree|mismatch)\b/.test( + `${text} ${relatedText}`, + ) || + relatedNodes.some( + (relatedNode) => + relatedNode.status === "contradicted" || + relatedNode.kind === "conclusion", + ); - if (hasDecisionValueLanguage && hasMeasurementLanguage) { - return { strategy: "measurement", meaning, actionPhrase }; - } - - if (hasPrimaryDefinitionLanguage) { - return { strategy: "definition", meaning, actionPhrase }; + if (hasPrimaryDefinitionLanguage || hasDefinitionLanguage) { + return buildInvestigationStrategy({ + key: "definition", + reason: + "Selected because the unknown is primarily about clarifying what a term means in this case.", + node, + graph, + relatedNodes, + meaning, + actionPhrase, + }); } if (hasDecisionValueLanguage || hasCriteriaLanguage) { - return { strategy: "decision criterion", meaning, actionPhrase }; + return buildInvestigationStrategy({ + key: "decision_threshold", + reason: + "Selected because the unknown determines the threshold for making or justifying a decision.", + node, + graph, + relatedNodes, + meaning, + actionPhrase, + }); } - if (hasConstraintLanguage && hasPrimaryConstraintLanguage) { - return { strategy: "constraint", meaning, actionPhrase }; + if (hasPrimaryBaselineLanguage || hasBaselineLanguage) { + return buildInvestigationStrategy({ + key: "baseline_reconstruction", + reason: + "Selected because reconstructing the prior state is the most direct way to resolve the unknown.", + node, + graph, + relatedNodes, + meaning, + actionPhrase, + }); } - if (hasDefinitionLanguage) { - return { strategy: "definition", meaning, actionPhrase }; + if (hasContradictionLanguage) { + return buildInvestigationStrategy({ + key: "contradiction_resolution", + reason: + "Selected because the graph context indicates conflicting claims or inconsistent states that must be reconciled.", + node, + graph, + relatedNodes, + meaning, + actionPhrase, + }); } - if (hasBaselineLanguage) { - return { strategy: "baseline", meaning, actionPhrase }; + if (hasEvidenceLanguage || hasMeasurementLanguage || hasConstraintLanguage) { + return buildInvestigationStrategy({ + key: "evidence_gathering", + reason: + hasConstraintLanguage && hasPrimaryConstraintLanguage + ? "Selected because evidence about the practical limiting factor is needed before the unknown can be resolved." + : "Selected because resolving the unknown requires evidence, signals, or measurable confirmation.", + node, + graph, + relatedNodes, + meaning, + actionPhrase, + }); } - if (hasConstraintLanguage) { - return { strategy: "constraint", meaning, actionPhrase }; - } - - if (/\b(evidence|proof|validate|validation|signal|demand)\b/.test(text)) { - return { strategy: "evidence", meaning, actionPhrase }; - } - - if (hasMeasurementLanguage) { - return { strategy: "measurement", meaning, actionPhrase }; - } - - if ( - /\b(objective|goal|outcome|problem|job to be done|benefit)\b/.test(text) - ) { - return { strategy: "objective", meaning, actionPhrase }; - } - - if ( - node?.kind === "reported_claim" || - node?.kind === "conclusion" || - /\b(claim|assertion|true|false)\b/.test(text) - ) { - return { strategy: "evidence", meaning, actionPhrase }; - } - - return { strategy: "generic clarification", meaning, actionPhrase }; + return null; } -function buildQuestion({ strategy, meaning, actionPhrase }) { - switch (strategy) { - case "decision criterion": - return actionPhrase - ? `What outcome would demonstrate enough value to justify ${toGerundPhrase(actionPhrase)}?` +function buildQuestionFromStrategy(strategy) { + switch (strategy.key) { + case "decision_threshold": + return strategy.actionPhrase + ? `What outcome would demonstrate enough value to justify ${toGerundPhrase(strategy.actionPhrase)}?` : "What outcome would be sufficient to justify this decision?"; case "definition": - return `What does ${meaning} mean in this situation?`; - case "evidence": - return `What evidence would show whether ${meaning} is true?`; - case "baseline": - return `What was the comparable state before ${meaning}?`; - case "actor/customer": - return "Who experiences the problem or receives the value in this situation?"; - case "objective": - return "What outcome is this decision or effort meant to achieve?"; - case "constraint": - return "What constraint most limits the available options in this situation?"; - case "measurement": - return `What measure would determine whether ${meaning} is sufficient?`; - case "transition/timing": - return `When does ${meaning} become relevant in the decision or change?`; + return `What does ${strategy.meaning} mean in this situation?`; + case "evidence_gathering": + return `What evidence would clarify ${stripTrailingPunctuation(strategy.meaning)}?`; + case "baseline_reconstruction": + return `What was the comparable state before ${strategy.meaning}?`; + case "contradiction_resolution": + return `What fact would resolve the contradiction about ${strategy.meaning}?`; default: - return `What specific fact would resolve whether ${meaning} is true?`; + return `What specific fact would resolve whether ${strategy.meaning} is true?`; } } @@ -315,6 +979,9 @@ function validateFormulatedQuestion(question, meaning) { if (/^how should uncertainty regarding\b/i.test(trimmed)) return false; if (/^what would resolve uncertainty regarding\b/i.test(trimmed)) return false; + if (/\)\.\s+[A-Z]/.test(trimmed)) return false; + if (/\bis true\?$/i.test(trimmed) && !/^whether\b/i.test(meaning)) + return false; if ( /\bprice|pricing|price point\b/i.test(trimmed) && !/\bprice\b/i.test(meaning) @@ -332,36 +999,57 @@ function validateFormulatedQuestion(question, meaning) { } export function formulateQuestion({ node, graph, context = {} }) { - const relatedNodes = collectRelatedNodes(node, graph); - const meaning = extractMeaning(node); - const combinedText = [ - node?.label, - node?.description, - ...relatedNodes.map((relatedNode) => relatedNode.label), - ...relatedNodes.map((relatedNode) => relatedNode.description), - graph?.centralStatement, - ...(context.resolvedValues || []), - ] - .filter(Boolean) - .join(" "); + if (context.selectionState?.status === "ambiguous") { + return formulateTieResolutionQuestion({ graph }); + } - const detected = detectStrategy({ + const investigationStrategy = selectInvestigationStrategy({ node, graph, - relatedNodes, - combinedText, - meaning, + context, }); - let question = buildQuestion(detected); + let question = investigationStrategy + ? buildQuestionFromStrategy(investigationStrategy) + : isRelationshipExplanationUnknown(node, graph) + ? buildBroadInvestigationQuestion(graph) + : buildNeutralClarificationQuestion(extractMeaning(node)); - if (!validateFormulatedQuestion(question, meaning)) { - question = `What evidence would resolve whether ${meaning} is true?`; + question = sanitizeQuestionText(question); + + const fallbackMeaning = extractMeaning(node); + if ( + !validateFormulatedQuestion( + question, + investigationStrategy?.meaning || fallbackMeaning, + ) + ) { + question = sanitizeQuestionText( + investigationStrategy && + isClaimLikeUnknown( + node, + normaliseText( + collectRelatedNodes(node, graph) + .map( + (relatedNode) => + `${relatedNode.label} ${relatedNode.description}`, + ) + .concat([node?.label, node?.description]) + .filter(Boolean) + .join(" "), + ), + ) + ? buildEvidenceFallbackQuestion(fallbackMeaning) + : buildNeutralClarificationQuestion(fallbackMeaning), + ); } return { question, - reason: `Formulated from graph context using the ${detected.strategy} strategy.`, - strategy: detected.strategy, + reason: investigationStrategy + ? `Formulated from graph context using the ${investigationStrategy.key} investigation strategy.` + : "Formulated as a neutral clarification question because no narrower investigation strategy clearly applied.", + strategy: investigationStrategy?.key ?? null, + investigationStrategy, }; } diff --git a/lib/graph/schema.js b/lib/graph/schema.js index eff3ef6..fbe173a 100644 --- a/lib/graph/schema.js +++ b/lib/graph/schema.js @@ -36,6 +36,20 @@ export const ConfidenceLevel = /** @type {const} */ ({ high: "high", }); +export const CompletenessStatus = /** @type {const} */ ({ + empty: "empty", + partial: "partial", + complete: "complete", +}); + +export const confidenceAssessmentSchema = z + .object({ + evidenceConfidence: z.enum(Object.values(ConfidenceLevel)), + completenessStatus: z.enum(Object.values(CompletenessStatus)), + conclusionConfidence: z.enum(Object.values(ConfidenceLevel)), + }) + .strict(); + // ── SituationNode ──────────────────────────────────── export const situationNodeSchema = z.object({ @@ -45,6 +59,7 @@ export const situationNodeSchema = z.object({ kind: z.enum(Object.values(SituationKind)), status: z.enum(Object.values(SituationStatus)), confidence: z.enum(Object.values(ConfidenceLevel)), + confidenceAssessment: confidenceAssessmentSchema.optional(), value: z.union([z.string(), z.number(), z.null()]).nullable().optional(), unit: z.string().nullable().optional(), evidenceIds: z.array(z.string()).default([]), @@ -84,6 +99,25 @@ export const situationEdgeSchema = z.object({ // ── SituationGraph ─────────────────────────────────── +const reasoningStageSchema = z.object({ + stage: z.string().min(1), + status: z.string().min(1), + outcome: z.string().min(1), +}); + +export const reasoningStateSchema = z + .object({ + comparabilityStatus: z.string().min(1).nullable().optional(), + comparabilityReason: z.string().min(1).nullable().optional(), + comparabilityEvidence: z.array(z.string()).default([]), + relationshipStatus: z.string().min(1).nullable().optional(), + relationshipReason: z.string().min(1).nullable().optional(), + relationshipAssessed: z.boolean().optional(), + contradictionReasoningAllowed: z.boolean().optional(), + reasoningStages: z.array(reasoningStageSchema).default([]), + }) + .strict(); + export const situationGraphSchema = z.object({ centralStatement: z.string().min(1), nodes: z.array(situationNodeSchema).min(1), @@ -91,6 +125,7 @@ export const situationGraphSchema = z.object({ activeUnknownNodeId: z.string().nullable(), resolvedNodeIds: z.array(z.string()).default([]), currentSummary: z.string().min(1), + reasoningState: reasoningStateSchema.optional(), }); /** @typedef {z.infer