Merge branch 'feature/question-strategy-alignment-v0.6'
This commit is contained in:
@@ -80,6 +80,14 @@ export default function DiagnosticsView({ result }) {
|
|||||||
? `${validationIcons.valid} valid`
|
? `${validationIcons.valid} valid`
|
||||||
: `${validationIcons.invalid} invalid`,
|
: `${validationIcons.invalid} invalid`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Investigation strategy",
|
||||||
|
value:
|
||||||
|
diagnostics.investigationStrategy?.key ||
|
||||||
|
diagnostics.investigationStrategy ||
|
||||||
|
result.selectedQuestion?.strategy ||
|
||||||
|
"?",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const errors = [
|
const errors = [
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export default function GraphUpdateView({ updateResult }) {
|
|||||||
proposal,
|
proposal,
|
||||||
previousSituationGraph,
|
previousSituationGraph,
|
||||||
updatedSituationGraph,
|
updatedSituationGraph,
|
||||||
|
reasoningState,
|
||||||
|
previousReasoningState,
|
||||||
} = updateResult;
|
} = updateResult;
|
||||||
|
|
||||||
const newlySurfacedUnknownNodeIds = (proposal.addedNodes || [])
|
const newlySurfacedUnknownNodeIds = (proposal.addedNodes || [])
|
||||||
@@ -64,6 +66,11 @@ export default function GraphUpdateView({ updateResult }) {
|
|||||||
<div className="text-xs text-gray-600">
|
<div className="text-xs text-gray-600">
|
||||||
{node.kind} · {node.confidence}
|
{node.kind} · {node.confidence}
|
||||||
</div>
|
</div>
|
||||||
|
{node.confidenceAssessment && (
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
evidence {node.confidenceAssessment.evidenceConfidence} · completeness {node.confidenceAssessment.completenessStatus} · conclusion {node.confidenceAssessment.conclusionConfidence}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(update?.previousStatus || update?.newStatus || node.status) && (
|
{(update?.previousStatus || update?.newStatus || node.status) && (
|
||||||
<div className="text-xs text-gray-700">
|
<div className="text-xs text-gray-700">
|
||||||
{update?.previousStatus ? `Previous status: ${update.previousStatus}` : null}
|
{update?.previousStatus ? `Previous status: ${update.previousStatus}` : null}
|
||||||
@@ -109,6 +116,23 @@ export default function GraphUpdateView({ updateResult }) {
|
|||||||
: null,
|
: null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const previousComparabilityStatus =
|
||||||
|
previousReasoningState?.comparabilityStatus ||
|
||||||
|
previousSituationGraph?.reasoningState?.comparabilityStatus ||
|
||||||
|
null;
|
||||||
|
const newComparabilityStatus =
|
||||||
|
reasoningState?.comparabilityStatus ||
|
||||||
|
updatedSituationGraph?.reasoningState?.comparabilityStatus ||
|
||||||
|
null;
|
||||||
|
const relationshipStatus =
|
||||||
|
reasoningState?.relationshipStatus ||
|
||||||
|
updatedSituationGraph?.reasoningState?.relationshipStatus ||
|
||||||
|
null;
|
||||||
|
const reasoningStagesAfter =
|
||||||
|
reasoningState?.reasoningStages ||
|
||||||
|
updatedSituationGraph?.reasoningState?.reasoningStages ||
|
||||||
|
[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<section className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
<section className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
@@ -134,12 +158,32 @@ export default function GraphUpdateView({ updateResult }) {
|
|||||||
{selectedQuestion.question}
|
{selectedQuestion.question}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{previousComparabilityStatus && newComparabilityStatus && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Comparability:</span>{" "}
|
||||||
|
{previousComparabilityStatus} → {newComparabilityStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{relationshipStatus && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Relationship status:</span>{" "}
|
||||||
|
{relationshipStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!selectedQuestion?.question && !newActiveUnknownNodeId && previousActiveUnknownNodeId && (
|
{!selectedQuestion?.question && !newActiveUnknownNodeId && previousActiveUnknownNodeId && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Next question status:</span> No next question selected yet.
|
<span className="font-medium">Next question status:</span> No next question selected yet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{reasoningStagesAfter.length > 0 && (
|
||||||
|
<div className="mt-3 text-sm text-blue-950">
|
||||||
|
<span className="font-medium">Reasoning stages:</span>{" "}
|
||||||
|
{reasoningStagesAfter
|
||||||
|
.map((stage) => `${stage.stage}: ${stage.status}`)
|
||||||
|
.join(" → ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ListSection
|
<ListSection
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ function NodeGroup({
|
|||||||
<span className="font-medium text-gray-900">{node.label}</span>
|
<span className="font-medium text-gray-900">{node.label}</span>
|
||||||
<NodeBadge tone="blue">{node.status}</NodeBadge>
|
<NodeBadge tone="blue">{node.status}</NodeBadge>
|
||||||
<NodeBadge tone="green">{node.confidence}</NodeBadge>
|
<NodeBadge tone="green">{node.confidence}</NodeBadge>
|
||||||
|
{node.confidenceAssessment?.completenessStatus && (
|
||||||
|
<NodeBadge tone="purple">
|
||||||
|
completeness: {node.confidenceAssessment.completenessStatus}
|
||||||
|
</NodeBadge>
|
||||||
|
)}
|
||||||
{resolvedNodeIds.has(node.id) && (
|
{resolvedNodeIds.has(node.id) && (
|
||||||
<NodeBadge tone="red">resolved unknown</NodeBadge>
|
<NodeBadge tone="red">resolved unknown</NodeBadge>
|
||||||
)}
|
)}
|
||||||
@@ -59,6 +64,12 @@ function NodeGroup({
|
|||||||
{node.description && node.description !== node.label && (
|
{node.description && node.description !== node.label && (
|
||||||
<p className="mt-1 text-gray-600">{node.description}</p>
|
<p className="mt-1 text-gray-600">{node.description}</p>
|
||||||
)}
|
)}
|
||||||
|
{node.confidenceAssessment && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
evidence: {node.confidenceAssessment.evidenceConfidence} ·
|
||||||
|
conclusion: {node.confidenceAssessment.conclusionConfidence}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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?`
|
||||||
@@ -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.
|
||||||
+1556
-24
File diff suppressed because it is too large
Load Diff
+336
-9
@@ -15,8 +15,13 @@ import {
|
|||||||
import { buildInitialGraph, describeGraph } from "./builder.js";
|
import { buildInitialGraph, describeGraph } from "./builder.js";
|
||||||
import { applyValidatedProposal } from "./apply-proposal.js";
|
import { applyValidatedProposal } from "./apply-proposal.js";
|
||||||
import { buildGraphUpdatePrompt } from "./prompt-builder.js";
|
import { buildGraphUpdatePrompt } from "./prompt-builder.js";
|
||||||
|
import {
|
||||||
|
buildReasoningState,
|
||||||
|
formulateTieResolutionQuestion,
|
||||||
|
} from "./question-formulator.js";
|
||||||
import { parseGraphUpdateProposal } from "./update-proposal.js";
|
import { parseGraphUpdateProposal } from "./update-proposal.js";
|
||||||
import {
|
import {
|
||||||
|
explainUnknownSelection,
|
||||||
selectActiveUnknownCandidate,
|
selectActiveUnknownCandidate,
|
||||||
validateGraphReferences,
|
validateGraphReferences,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
@@ -31,7 +36,12 @@ function toValidationErrors(error) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDiagnostics({ analysis, graph, graphReferenceValidation }) {
|
function buildDiagnostics({
|
||||||
|
analysis,
|
||||||
|
graph,
|
||||||
|
graphReferenceValidation,
|
||||||
|
unknownSelectionExplanation,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
promptVersion: analysis?.promptVersion ?? null,
|
promptVersion: analysis?.promptVersion ?? null,
|
||||||
modelName: analysis?.modelName ?? null,
|
modelName: analysis?.modelName ?? null,
|
||||||
@@ -43,9 +53,30 @@ function buildDiagnostics({ analysis, graph, graphReferenceValidation }) {
|
|||||||
compatibilityApplied: analysis?.compatibilityApplied ?? false,
|
compatibilityApplied: analysis?.compatibilityApplied ?? false,
|
||||||
compatibilityChanges: analysis?.compatibilityChanges ?? [],
|
compatibilityChanges: analysis?.compatibilityChanges ?? [],
|
||||||
compatibilityWarnings: analysis?.compatibilityWarnings ?? [],
|
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({
|
function buildUpdateDiagnostics({
|
||||||
promptVersion,
|
promptVersion,
|
||||||
modelName,
|
modelName,
|
||||||
@@ -53,6 +84,55 @@ function buildUpdateDiagnostics({
|
|||||||
normalisationsApplied,
|
normalisationsApplied,
|
||||||
graph,
|
graph,
|
||||||
graphReferenceValidation,
|
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 {
|
return {
|
||||||
promptVersion: promptVersion ?? "v0.4",
|
promptVersion: promptVersion ?? "v0.4",
|
||||||
@@ -66,6 +146,63 @@ function buildUpdateDiagnostics({
|
|||||||
errors: [],
|
errors: [],
|
||||||
},
|
},
|
||||||
normalisationsApplied: normalisationsApplied ?? [],
|
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 currentSummary = describeGraph(initialGraph);
|
||||||
|
const deterministicSelection = selectActiveUnknownCandidate(
|
||||||
|
{
|
||||||
|
...initialGraph,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
const activeUnknownNodeId =
|
const activeUnknownNodeId =
|
||||||
selectActiveUnknownCandidate(
|
deterministicSelection?.status === "selected"
|
||||||
{
|
? deterministicSelection.nodeId
|
||||||
...initialGraph,
|
: null;
|
||||||
resolvedNodeIds: [],
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)?.nodeId ?? null;
|
|
||||||
|
|
||||||
const situationGraph = makeGraph({
|
const situationGraph = makeGraph({
|
||||||
centralStatement: scenario,
|
centralStatement: scenario,
|
||||||
@@ -121,11 +261,30 @@ export async function startCase(body) {
|
|||||||
activeUnknownNodeId,
|
activeUnknownNodeId,
|
||||||
resolvedNodeIds: [],
|
resolvedNodeIds: [],
|
||||||
currentSummary,
|
currentSummary,
|
||||||
|
reasoningState: buildReasoningState({
|
||||||
|
centralStatement: scenario,
|
||||||
|
nodes: initialGraph.nodes,
|
||||||
|
edges: initialGraph.edges,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
situationGraphSchema.parse(situationGraph);
|
situationGraphSchema.parse(situationGraph);
|
||||||
|
|
||||||
const graphReferenceValidation = validateGraphReferences(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) {
|
if (!graphReferenceValidation.valid) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -134,6 +293,7 @@ export async function startCase(body) {
|
|||||||
analysis,
|
analysis,
|
||||||
graph: situationGraph,
|
graph: situationGraph,
|
||||||
graphReferenceValidation,
|
graphReferenceValidation,
|
||||||
|
unknownSelectionExplanation,
|
||||||
}),
|
}),
|
||||||
validationErrors: graphReferenceValidation.errors,
|
validationErrors: graphReferenceValidation.errors,
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
@@ -143,11 +303,12 @@ export async function startCase(body) {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
situationGraph,
|
situationGraph,
|
||||||
selectedQuestion: analysis.nextQuestion ?? null,
|
selectedQuestion,
|
||||||
diagnostics: buildDiagnostics({
|
diagnostics: buildDiagnostics({
|
||||||
analysis,
|
analysis,
|
||||||
graph: situationGraph,
|
graph: situationGraph,
|
||||||
graphReferenceValidation,
|
graphReferenceValidation,
|
||||||
|
unknownSelectionExplanation,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -269,6 +430,8 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
const applicationResult = applyProposalUpdate({
|
const applicationResult = applyProposalUpdate({
|
||||||
situationGraph,
|
situationGraph,
|
||||||
proposal: parsedProposal.proposal,
|
proposal: parsedProposal.proposal,
|
||||||
|
previousQuestion,
|
||||||
|
answer,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!applicationResult.success) {
|
if (!applicationResult.success) {
|
||||||
@@ -284,6 +447,58 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
normalisationsApplied: parsedProposal.normalisationsApplied,
|
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||||
graph: situationGraph,
|
graph: situationGraph,
|
||||||
graphReferenceValidation: graphReferenceValidation,
|
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:
|
statusCode:
|
||||||
@@ -313,6 +528,65 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
normalisationsApplied: parsedProposal.normalisationsApplied,
|
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||||
graph: applicationResult.updatedSituationGraph,
|
graph: applicationResult.updatedSituationGraph,
|
||||||
graphReferenceValidation: applicationResult.graphReferenceValidation,
|
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,
|
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||||
graph: situationGraph,
|
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: buildUnknownSelectionDiagnostics(
|
||||||
|
situationGraph,
|
||||||
|
situationGraph.resolvedNodeIds || [],
|
||||||
|
null,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ function sentenceCase(value) {
|
|||||||
return trimmed.charAt(0).toLowerCase() + trimmed.slice(1);
|
return trimmed.charAt(0).toLowerCase() + trimmed.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripTrailingPunctuation(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/[.?!:;]+$/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function buildNodeMap(graph) {
|
function buildNodeMap(graph) {
|
||||||
return new Map((graph?.nodes || []).map((node) => [node.id, node]));
|
return new Map((graph?.nodes || []).map((node) => [node.id, node]));
|
||||||
}
|
}
|
||||||
@@ -52,8 +59,8 @@ function collectResolvedContextValues(graph) {
|
|||||||
|
|
||||||
function extractMeaning(node) {
|
function extractMeaning(node) {
|
||||||
const raw = `${node?.label || ""} ${node?.description || ""}`.trim();
|
const raw = `${node?.label || ""} ${node?.description || ""}`.trim();
|
||||||
let meaning = String(
|
let meaning = stripTrailingPunctuation(
|
||||||
node?.label || node?.description || "this uncertainty",
|
String(node?.label || node?.description || "this uncertainty"),
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const lowered = normaliseText(raw);
|
const lowered = normaliseText(raw);
|
||||||
@@ -79,6 +86,608 @@ function extractMeaning(node) {
|
|||||||
return sentenceCase(meaning);
|
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) {
|
function extractActionPhrase(texts) {
|
||||||
for (const text of texts) {
|
for (const text of texts) {
|
||||||
const value = String(text || "").trim();
|
const value = String(text || "").trim();
|
||||||
@@ -139,7 +748,41 @@ function toGerundPhrase(phrase) {
|
|||||||
return [gerund, ...rest].join(" ");
|
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 text = normaliseText(combinedText);
|
||||||
const nodeText = normaliseText(
|
const nodeText = normaliseText(
|
||||||
`${node?.label || ""} ${node?.description || ""}`,
|
`${node?.label || ""} ${node?.description || ""}`,
|
||||||
@@ -172,25 +815,25 @@ function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) {
|
|||||||
nodeText,
|
nodeText,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(text)) {
|
|
||||||
return { strategy: "actor/customer", meaning, actionPhrase };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasBaselineLanguage =
|
const hasBaselineLanguage =
|
||||||
/\b(before|previous|baseline|prior|comparable state)\b/.test(text);
|
/\b(before|previous|baseline|prior|comparable state)\b/.test(text);
|
||||||
const hasPrimaryBaselineLanguage =
|
const hasPrimaryBaselineLanguage =
|
||||||
/\b(before|previous|baseline|prior|comparable state)\b/.test(nodeText);
|
/\b(before|previous|baseline|prior|comparable state)\b/.test(nodeText);
|
||||||
|
|
||||||
if (hasBaselineLanguage && hasPrimaryBaselineLanguage) {
|
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)) {
|
const hasDefinitionLanguage = isDefinitionLikeUnknown(nodeText, text);
|
||||||
return { strategy: "transition/timing", meaning, actionPhrase };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDefinitionLanguage =
|
|
||||||
/\b(define|definition|meaning|term|terminology)\b/.test(text);
|
|
||||||
const hasPrimaryDefinitionLanguage =
|
const hasPrimaryDefinitionLanguage =
|
||||||
/\b(define|definition|meaning|term|terminology)\b/.test(nodeText);
|
/\b(define|definition|meaning|term|terminology)\b/.test(nodeText);
|
||||||
const hasCriteriaLanguage =
|
const hasCriteriaLanguage =
|
||||||
@@ -206,84 +849,105 @@ function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) {
|
|||||||
/\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test(
|
/\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test(
|
||||||
text,
|
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) {
|
if (hasPrimaryDefinitionLanguage || hasDefinitionLanguage) {
|
||||||
return { strategy: "measurement", meaning, actionPhrase };
|
return buildInvestigationStrategy({
|
||||||
}
|
key: "definition",
|
||||||
|
reason:
|
||||||
if (hasPrimaryDefinitionLanguage) {
|
"Selected because the unknown is primarily about clarifying what a term means in this case.",
|
||||||
return { strategy: "definition", meaning, actionPhrase };
|
node,
|
||||||
|
graph,
|
||||||
|
relatedNodes,
|
||||||
|
meaning,
|
||||||
|
actionPhrase,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasDecisionValueLanguage || hasCriteriaLanguage) {
|
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) {
|
if (hasPrimaryBaselineLanguage || hasBaselineLanguage) {
|
||||||
return { strategy: "constraint", meaning, actionPhrase };
|
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) {
|
if (hasContradictionLanguage) {
|
||||||
return { strategy: "definition", meaning, actionPhrase };
|
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) {
|
if (hasEvidenceLanguage || hasMeasurementLanguage || hasConstraintLanguage) {
|
||||||
return { strategy: "baseline", meaning, actionPhrase };
|
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 null;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQuestion({ strategy, meaning, actionPhrase }) {
|
function buildQuestionFromStrategy(strategy) {
|
||||||
switch (strategy) {
|
switch (strategy.key) {
|
||||||
case "decision criterion":
|
case "decision_threshold":
|
||||||
return actionPhrase
|
return strategy.actionPhrase
|
||||||
? `What outcome would demonstrate enough value to justify ${toGerundPhrase(actionPhrase)}?`
|
? `What outcome would demonstrate enough value to justify ${toGerundPhrase(strategy.actionPhrase)}?`
|
||||||
: "What outcome would be sufficient to justify this decision?";
|
: "What outcome would be sufficient to justify this decision?";
|
||||||
case "definition":
|
case "definition":
|
||||||
return `What does ${meaning} mean in this situation?`;
|
return `What does ${strategy.meaning} mean in this situation?`;
|
||||||
case "evidence":
|
case "evidence_gathering":
|
||||||
return `What evidence would show whether ${meaning} is true?`;
|
return `What evidence would clarify ${stripTrailingPunctuation(strategy.meaning)}?`;
|
||||||
case "baseline":
|
case "baseline_reconstruction":
|
||||||
return `What was the comparable state before ${meaning}?`;
|
return `What was the comparable state before ${strategy.meaning}?`;
|
||||||
case "actor/customer":
|
case "contradiction_resolution":
|
||||||
return "Who experiences the problem or receives the value in this situation?";
|
return `What fact would resolve the contradiction about ${strategy.meaning}?`;
|
||||||
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?`;
|
|
||||||
default:
|
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 (/^how should uncertainty regarding\b/i.test(trimmed)) return false;
|
||||||
if (/^what would resolve uncertainty regarding\b/i.test(trimmed))
|
if (/^what would resolve uncertainty regarding\b/i.test(trimmed))
|
||||||
return false;
|
return false;
|
||||||
|
if (/\)\.\s+[A-Z]/.test(trimmed)) return false;
|
||||||
|
if (/\bis true\?$/i.test(trimmed) && !/^whether\b/i.test(meaning))
|
||||||
|
return false;
|
||||||
if (
|
if (
|
||||||
/\bprice|pricing|price point\b/i.test(trimmed) &&
|
/\bprice|pricing|price point\b/i.test(trimmed) &&
|
||||||
!/\bprice\b/i.test(meaning)
|
!/\bprice\b/i.test(meaning)
|
||||||
@@ -332,36 +999,57 @@ function validateFormulatedQuestion(question, meaning) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formulateQuestion({ node, graph, context = {} }) {
|
export function formulateQuestion({ node, graph, context = {} }) {
|
||||||
const relatedNodes = collectRelatedNodes(node, graph);
|
if (context.selectionState?.status === "ambiguous") {
|
||||||
const meaning = extractMeaning(node);
|
return formulateTieResolutionQuestion({ graph });
|
||||||
const combinedText = [
|
}
|
||||||
node?.label,
|
|
||||||
node?.description,
|
|
||||||
...relatedNodes.map((relatedNode) => relatedNode.label),
|
|
||||||
...relatedNodes.map((relatedNode) => relatedNode.description),
|
|
||||||
graph?.centralStatement,
|
|
||||||
...(context.resolvedValues || []),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const detected = detectStrategy({
|
const investigationStrategy = selectInvestigationStrategy({
|
||||||
node,
|
node,
|
||||||
graph,
|
graph,
|
||||||
relatedNodes,
|
context,
|
||||||
combinedText,
|
|
||||||
meaning,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let question = buildQuestion(detected);
|
let question = investigationStrategy
|
||||||
|
? buildQuestionFromStrategy(investigationStrategy)
|
||||||
|
: isRelationshipExplanationUnknown(node, graph)
|
||||||
|
? buildBroadInvestigationQuestion(graph)
|
||||||
|
: buildNeutralClarificationQuestion(extractMeaning(node));
|
||||||
|
|
||||||
if (!validateFormulatedQuestion(question, meaning)) {
|
question = sanitizeQuestionText(question);
|
||||||
question = `What evidence would resolve whether ${meaning} is true?`;
|
|
||||||
|
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 {
|
return {
|
||||||
question,
|
question,
|
||||||
reason: `Formulated from graph context using the ${detected.strategy} strategy.`,
|
reason: investigationStrategy
|
||||||
strategy: detected.strategy,
|
? `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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ export const ConfidenceLevel = /** @type {const} */ ({
|
|||||||
high: "high",
|
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 ────────────────────────────────────
|
// ── SituationNode ────────────────────────────────────
|
||||||
|
|
||||||
export const situationNodeSchema = z.object({
|
export const situationNodeSchema = z.object({
|
||||||
@@ -45,6 +59,7 @@ export const situationNodeSchema = z.object({
|
|||||||
kind: z.enum(Object.values(SituationKind)),
|
kind: z.enum(Object.values(SituationKind)),
|
||||||
status: z.enum(Object.values(SituationStatus)),
|
status: z.enum(Object.values(SituationStatus)),
|
||||||
confidence: z.enum(Object.values(ConfidenceLevel)),
|
confidence: z.enum(Object.values(ConfidenceLevel)),
|
||||||
|
confidenceAssessment: confidenceAssessmentSchema.optional(),
|
||||||
value: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
value: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||||
unit: z.string().nullable().optional(),
|
unit: z.string().nullable().optional(),
|
||||||
evidenceIds: z.array(z.string()).default([]),
|
evidenceIds: z.array(z.string()).default([]),
|
||||||
@@ -84,6 +99,25 @@ export const situationEdgeSchema = z.object({
|
|||||||
|
|
||||||
// ── SituationGraph ───────────────────────────────────
|
// ── 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({
|
export const situationGraphSchema = z.object({
|
||||||
centralStatement: z.string().min(1),
|
centralStatement: z.string().min(1),
|
||||||
nodes: z.array(situationNodeSchema).min(1),
|
nodes: z.array(situationNodeSchema).min(1),
|
||||||
@@ -91,6 +125,7 @@ export const situationGraphSchema = z.object({
|
|||||||
activeUnknownNodeId: z.string().nullable(),
|
activeUnknownNodeId: z.string().nullable(),
|
||||||
resolvedNodeIds: z.array(z.string()).default([]),
|
resolvedNodeIds: z.array(z.string()).default([]),
|
||||||
currentSummary: z.string().min(1),
|
currentSummary: z.string().min(1),
|
||||||
|
reasoningState: reasoningStateSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @typedef {z.infer<typeof situationGraphSchema>} SituationGraph */
|
/** @typedef {z.infer<typeof situationGraphSchema>} SituationGraph */
|
||||||
@@ -168,6 +203,7 @@ export function makeNode(opts) {
|
|||||||
kind: opts.kind ?? "observation",
|
kind: opts.kind ?? "observation",
|
||||||
status: opts.status ?? "unknown",
|
status: opts.status ?? "unknown",
|
||||||
confidence: opts.confidence ?? "medium",
|
confidence: opts.confidence ?? "medium",
|
||||||
|
confidenceAssessment: opts.confidenceAssessment,
|
||||||
value: opts.value ?? null,
|
value: opts.value ?? null,
|
||||||
unit: opts.unit ?? null,
|
unit: opts.unit ?? null,
|
||||||
evidenceIds: opts.evidenceIds ?? [],
|
evidenceIds: opts.evidenceIds ?? [],
|
||||||
@@ -201,5 +237,6 @@ export function makeGraph(opts) {
|
|||||||
activeUnknownNodeId: opts.activeUnknownNodeId ?? null,
|
activeUnknownNodeId: opts.activeUnknownNodeId ?? null,
|
||||||
resolvedNodeIds: opts.resolvedNodeIds ?? [],
|
resolvedNodeIds: opts.resolvedNodeIds ?? [],
|
||||||
currentSummary: opts.currentSummary || "",
|
currentSummary: opts.currentSummary || "",
|
||||||
|
reasoningState: opts.reasoningState,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+376
-35
@@ -95,6 +95,240 @@ function classifyUnknownPriority(text) {
|
|||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildScoreContributions(
|
||||||
|
matches,
|
||||||
|
downstreamCount,
|
||||||
|
unresolvedParentUnknownCount,
|
||||||
|
) {
|
||||||
|
const contributions = [
|
||||||
|
{
|
||||||
|
rule: "downstream_dependencies",
|
||||||
|
value: downstreamCount,
|
||||||
|
weight: 4,
|
||||||
|
delta: downstreamCount * 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (matches.objective) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "objective_match",
|
||||||
|
value: true,
|
||||||
|
weight: 12,
|
||||||
|
delta: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.actor) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "actor_match",
|
||||||
|
value: true,
|
||||||
|
weight: 10,
|
||||||
|
delta: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.criteria) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "criteria_match",
|
||||||
|
value: true,
|
||||||
|
weight: 11,
|
||||||
|
delta: 11,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.measure) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "measure_match",
|
||||||
|
value: true,
|
||||||
|
weight: 8,
|
||||||
|
delta: 8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.terminology) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "terminology_match",
|
||||||
|
value: true,
|
||||||
|
weight: 7,
|
||||||
|
delta: 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.constraint) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "constraint_match",
|
||||||
|
value: true,
|
||||||
|
weight: 9,
|
||||||
|
delta: 9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.pricing) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "pricing_penalty",
|
||||||
|
value: true,
|
||||||
|
weight: -8,
|
||||||
|
delta: -8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.implementation) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "implementation_penalty",
|
||||||
|
value: true,
|
||||||
|
weight: -10,
|
||||||
|
delta: -10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.optimisation) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "optimisation_penalty",
|
||||||
|
value: true,
|
||||||
|
weight: -9,
|
||||||
|
delta: -9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (matches.speculative) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "speculative_penalty",
|
||||||
|
value: true,
|
||||||
|
weight: -12,
|
||||||
|
delta: -12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
matches.pricing &&
|
||||||
|
!matches.objective &&
|
||||||
|
!matches.criteria &&
|
||||||
|
!matches.actor
|
||||||
|
) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "isolated_pricing_penalty",
|
||||||
|
value: true,
|
||||||
|
weight: -6,
|
||||||
|
delta: -6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unresolvedParentUnknownCount > 0) {
|
||||||
|
contributions.push({
|
||||||
|
rule: "unresolved_prerequisite_penalty",
|
||||||
|
value: unresolvedParentUnknownCount,
|
||||||
|
weight: -7,
|
||||||
|
delta: unresolvedParentUnknownCount * -7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return contributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeaningfulSemanticContributions(contributions = []) {
|
||||||
|
return contributions
|
||||||
|
.filter(
|
||||||
|
(contribution) =>
|
||||||
|
contribution.rule !== "downstream_dependencies" &&
|
||||||
|
contribution.rule !== "unresolved_prerequisite_penalty" &&
|
||||||
|
contribution.delta !== 0,
|
||||||
|
)
|
||||||
|
.map((contribution) => ({
|
||||||
|
rule: contribution.rule,
|
||||||
|
delta: contribution.delta,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCandidateDisplayOrder(candidates) {
|
||||||
|
return [...candidates].sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
if (b.downstreamCount !== a.downstreamCount) {
|
||||||
|
return b.downstreamCount - a.downstreamCount;
|
||||||
|
}
|
||||||
|
if (a.unresolvedParentUnknownCount !== b.unresolvedParentUnknownCount) {
|
||||||
|
return a.unresolvedParentUnknownCount - b.unresolvedParentUnknownCount;
|
||||||
|
}
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticSignature(candidate) {
|
||||||
|
return JSON.stringify(
|
||||||
|
getMeaningfulSemanticContributions(candidate.contributions),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyCandidateOrdering(candidates) {
|
||||||
|
const displayOrder = buildCandidateDisplayOrder(candidates);
|
||||||
|
const best = displayOrder[0] ?? null;
|
||||||
|
if (!best) {
|
||||||
|
return {
|
||||||
|
displayOrder,
|
||||||
|
best: null,
|
||||||
|
leadingCandidates: [],
|
||||||
|
status: "no_candidates",
|
||||||
|
tieType: "none",
|
||||||
|
usedAlphabeticalOrdering: false,
|
||||||
|
reason: "No unresolved unknown candidates remain.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const topScoreCandidates = displayOrder.filter(
|
||||||
|
(candidate) => candidate.score === best.score,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (topScoreCandidates.length === 1) {
|
||||||
|
return {
|
||||||
|
displayOrder,
|
||||||
|
best,
|
||||||
|
leadingCandidates: [best],
|
||||||
|
status: "selected",
|
||||||
|
tieType: "none",
|
||||||
|
usedAlphabeticalOrdering: false,
|
||||||
|
reason: `Clear winner by total score (${best.score}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const topStructuralCandidates = topScoreCandidates.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.downstreamCount === best.downstreamCount &&
|
||||||
|
candidate.unresolvedParentUnknownCount ===
|
||||||
|
best.unresolvedParentUnknownCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (topStructuralCandidates.length === 1) {
|
||||||
|
return {
|
||||||
|
displayOrder,
|
||||||
|
best,
|
||||||
|
leadingCandidates: [best],
|
||||||
|
status: "selected",
|
||||||
|
tieType: "structural_tie",
|
||||||
|
usedAlphabeticalOrdering: false,
|
||||||
|
reason:
|
||||||
|
"Score tie was resolved by downstream dependency count or prerequisite ordering.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const topSemanticSignature = semanticSignature(best);
|
||||||
|
const semanticPeers = topStructuralCandidates.filter(
|
||||||
|
(candidate) => semanticSignature(candidate) === topSemanticSignature,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (semanticPeers.length !== topStructuralCandidates.length) {
|
||||||
|
return {
|
||||||
|
displayOrder,
|
||||||
|
best: null,
|
||||||
|
leadingCandidates: topStructuralCandidates,
|
||||||
|
status: "ambiguous",
|
||||||
|
tieType: "semantic_tie",
|
||||||
|
usedAlphabeticalOrdering: false,
|
||||||
|
reason:
|
||||||
|
"Leading candidates remain tied after score and structural checks, but differ in semantic contribution patterns.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayOrder,
|
||||||
|
best: null,
|
||||||
|
leadingCandidates: topStructuralCandidates,
|
||||||
|
status: "ambiguous",
|
||||||
|
tieType: "complete_unresolved_tie",
|
||||||
|
usedAlphabeticalOrdering: false,
|
||||||
|
reason: "No justified distinction between leading unknowns.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
|
export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
|
||||||
const text = collectNodeText(node);
|
const text = collectNodeText(node);
|
||||||
const matches = classifyUnknownPriority(text);
|
const matches = classifyUnknownPriority(text);
|
||||||
@@ -105,30 +339,15 @@ export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
|
|||||||
resolvedNodeIds,
|
resolvedNodeIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
let score = downstreamCount * 4;
|
const contributions = buildScoreContributions(
|
||||||
|
matches,
|
||||||
if (matches.objective) score += 12;
|
downstreamCount,
|
||||||
if (matches.actor) score += 10;
|
unresolvedParentUnknownCount,
|
||||||
if (matches.criteria) score += 11;
|
);
|
||||||
if (matches.measure) score += 8;
|
const score = contributions.reduce(
|
||||||
if (matches.terminology) score += 7;
|
(total, contribution) => total + contribution.delta,
|
||||||
if (matches.constraint) score += 9;
|
0,
|
||||||
|
);
|
||||||
if (matches.pricing) score -= 8;
|
|
||||||
if (matches.implementation) score -= 10;
|
|
||||||
if (matches.optimisation) score -= 9;
|
|
||||||
if (matches.speculative) score -= 12;
|
|
||||||
|
|
||||||
if (
|
|
||||||
matches.pricing &&
|
|
||||||
!matches.objective &&
|
|
||||||
!matches.criteria &&
|
|
||||||
!matches.actor
|
|
||||||
) {
|
|
||||||
score -= 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
score -= unresolvedParentUnknownCount * 7;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
@@ -137,6 +356,7 @@ export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
|
|||||||
downstreamCount,
|
downstreamCount,
|
||||||
unresolvedParentUnknownCount,
|
unresolvedParentUnknownCount,
|
||||||
matches,
|
matches,
|
||||||
|
contributions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,21 +603,36 @@ export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
|||||||
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
|
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
scoredCandidates.sort((a, b) => {
|
const selection = classifyCandidateOrdering(
|
||||||
if (b.score !== a.score) return b.score - a.score;
|
scoredCandidates.map(({ node, ...candidate }) => ({
|
||||||
if (b.downstreamCount !== a.downstreamCount) {
|
...candidate,
|
||||||
return b.downstreamCount - a.downstreamCount;
|
node,
|
||||||
}
|
})),
|
||||||
if (a.unresolvedParentUnknownCount !== b.unresolvedParentUnknownCount) {
|
);
|
||||||
return a.unresolvedParentUnknownCount - b.unresolvedParentUnknownCount;
|
|
||||||
}
|
|
||||||
return a.node.label.localeCompare(b.node.label);
|
|
||||||
});
|
|
||||||
|
|
||||||
const best = scoredCandidates[0];
|
if (selection.status === "ambiguous") {
|
||||||
|
return {
|
||||||
|
selectedNode: null,
|
||||||
|
status: "ambiguous",
|
||||||
|
tieType: selection.tieType,
|
||||||
|
tiedCandidateIds: selection.leadingCandidates.map(
|
||||||
|
(candidate) => candidate.nodeId,
|
||||||
|
),
|
||||||
|
displayOrder: selection.displayOrder.map((candidate) => candidate.nodeId),
|
||||||
|
reason: selection.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = selection.best;
|
||||||
if (!best) return null;
|
if (!best) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
selectedNode: {
|
||||||
|
nodeId: best.node.id,
|
||||||
|
label: best.node.label,
|
||||||
|
},
|
||||||
|
status: "selected",
|
||||||
|
tieType: selection.tieType,
|
||||||
nodeId: best.node.id,
|
nodeId: best.node.id,
|
||||||
label: best.node.label,
|
label: best.node.label,
|
||||||
score: best.score,
|
score: best.score,
|
||||||
@@ -406,6 +641,112 @@ export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function explainUnknownSelection(graph, resolvedNodeIds = []) {
|
||||||
|
const unresolved = graph.nodes.filter(
|
||||||
|
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unresolved.length === 0) {
|
||||||
|
return {
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedNodeLabel: null,
|
||||||
|
status: "no_candidates",
|
||||||
|
tieType: "none",
|
||||||
|
resolvedNodeIds: [...resolvedNodeIds],
|
||||||
|
tiedCandidateIds: [],
|
||||||
|
candidates: [],
|
||||||
|
competitors: [],
|
||||||
|
tieBreakOrder: [
|
||||||
|
"score_desc",
|
||||||
|
"downstreamCount_desc",
|
||||||
|
"unresolvedParentUnknownCount_asc",
|
||||||
|
"label_asc",
|
||||||
|
],
|
||||||
|
summary: {
|
||||||
|
candidateCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = unresolved.map((node) => ({
|
||||||
|
nodeId: node.id,
|
||||||
|
label: node.label,
|
||||||
|
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selection = classifyCandidateOrdering(candidates);
|
||||||
|
const orderedCandidates = selection.displayOrder;
|
||||||
|
const selected = selection.best;
|
||||||
|
const competitors = orderedCandidates
|
||||||
|
.filter((candidate) => candidate.nodeId !== selected?.nodeId)
|
||||||
|
.map((candidate) => ({
|
||||||
|
nodeId: candidate.nodeId,
|
||||||
|
label: candidate.label,
|
||||||
|
score: candidate.score,
|
||||||
|
downstreamCount: candidate.downstreamCount,
|
||||||
|
unresolvedParentUnknownCount: candidate.unresolvedParentUnknownCount,
|
||||||
|
matches: candidate.matches,
|
||||||
|
contributions: candidate.contributions,
|
||||||
|
outrankedBy: {
|
||||||
|
scoreDelta: (selected?.score ?? candidate.score) - candidate.score,
|
||||||
|
downstreamDelta:
|
||||||
|
(selected?.downstreamCount ?? candidate.downstreamCount) -
|
||||||
|
candidate.downstreamCount,
|
||||||
|
unresolvedPrerequisiteDelta:
|
||||||
|
candidate.unresolvedParentUnknownCount -
|
||||||
|
(selected?.unresolvedParentUnknownCount ??
|
||||||
|
candidate.unresolvedParentUnknownCount),
|
||||||
|
labelOrderWinner:
|
||||||
|
selected &&
|
||||||
|
selected.score === candidate.score &&
|
||||||
|
selected.downstreamCount === candidate.downstreamCount &&
|
||||||
|
selected.unresolvedParentUnknownCount ===
|
||||||
|
candidate.unresolvedParentUnknownCount
|
||||||
|
? selected.label.localeCompare(candidate.label) <= 0
|
||||||
|
? selected.label
|
||||||
|
: candidate.label
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedNodeId: selected?.nodeId ?? null,
|
||||||
|
selectedNodeLabel: selected?.label ?? null,
|
||||||
|
status: selection.status,
|
||||||
|
tieType: selection.tieType,
|
||||||
|
resolvedNodeIds: [...resolvedNodeIds],
|
||||||
|
tiedCandidateIds: selection.leadingCandidates.map(
|
||||||
|
(candidate) => candidate.nodeId,
|
||||||
|
),
|
||||||
|
tieBreakOrder: [
|
||||||
|
"score_desc",
|
||||||
|
"downstreamCount_desc",
|
||||||
|
"unresolvedParentUnknownCount_asc",
|
||||||
|
"label_asc",
|
||||||
|
],
|
||||||
|
alphabeticalUsedAsReasoning: false,
|
||||||
|
candidates: orderedCandidates,
|
||||||
|
selected: selected
|
||||||
|
? {
|
||||||
|
nodeId: selected.nodeId,
|
||||||
|
label: selected.label,
|
||||||
|
score: selected.score,
|
||||||
|
downstreamCount: selected.downstreamCount,
|
||||||
|
unresolvedParentUnknownCount: selected.unresolvedParentUnknownCount,
|
||||||
|
matches: selected.matches,
|
||||||
|
contributions: selected.contributions,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
competitors,
|
||||||
|
summary: {
|
||||||
|
candidateCount: orderedCandidates.length,
|
||||||
|
selectedReason: selected
|
||||||
|
? `highest_score=${selected.score}; downstream=${selected.downstreamCount}; unresolved_prerequisites=${selected.unresolvedParentUnknownCount}`
|
||||||
|
: selection.reason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Apply a graph update deterministically ──
|
// ── Apply a graph update deterministically ──
|
||||||
|
|
||||||
export function applyGraphUpdate(graph, update) {
|
export function applyGraphUpdate(graph, update) {
|
||||||
|
|||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function buildAmbiguityFixture({
|
||||||
|
key,
|
||||||
|
scenario,
|
||||||
|
summaryLabel,
|
||||||
|
contradictionLabel,
|
||||||
|
observationLabels,
|
||||||
|
unknownLabels,
|
||||||
|
disallowedQuestionTerms,
|
||||||
|
}) {
|
||||||
|
const summary = makeNode({
|
||||||
|
id: `${key}-summary`,
|
||||||
|
label: summaryLabel,
|
||||||
|
description: "Summary of the situation from the scenario text",
|
||||||
|
kind: "state",
|
||||||
|
status: "provisional",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const contradiction = makeNode({
|
||||||
|
id: `${key}-contradiction`,
|
||||||
|
label: contradictionLabel,
|
||||||
|
description: contradictionLabel,
|
||||||
|
kind: "relationship",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const observations = observationLabels.map((label, index) =>
|
||||||
|
makeNode({
|
||||||
|
id: `${key}-obs-${index + 1}`,
|
||||||
|
label,
|
||||||
|
description: label,
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unknowns = unknownLabels.map((label, index) =>
|
||||||
|
makeNode({
|
||||||
|
id: `${key}-unknown-${index + 1}`,
|
||||||
|
label,
|
||||||
|
description: label,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
...observations.map((node) =>
|
||||||
|
makeEdge({
|
||||||
|
id: `${node.id}-supports-summary`,
|
||||||
|
fromNodeId: node.id,
|
||||||
|
toNodeId: summary.id,
|
||||||
|
relationship: "supports",
|
||||||
|
description: `${node.label} supports the summary.`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...unknowns.map((node) =>
|
||||||
|
makeEdge({
|
||||||
|
id: `${node.id}-depends-summary`,
|
||||||
|
fromNodeId: node.id,
|
||||||
|
toNodeId: summary.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${node.label} is an unresolved factor for this situation.`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
scenario,
|
||||||
|
disallowedQuestionTerms,
|
||||||
|
graph: makeGraph({
|
||||||
|
centralStatement: scenario,
|
||||||
|
nodes: [summary, contradiction, ...observations, ...unknowns],
|
||||||
|
edges,
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: `Ambiguity fixture for ${key}`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ambiguityGeneralisationFixtures = [
|
||||||
|
buildAmbiguityFixture({
|
||||||
|
key: "revenue-cash",
|
||||||
|
scenario:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
summaryLabel: "Revenue rose while cash fell",
|
||||||
|
contradictionLabel:
|
||||||
|
"Contradiction between revenue improvement and lower cash reserves.",
|
||||||
|
observationLabels: [
|
||||||
|
"Revenue increased by 18%.",
|
||||||
|
"Cash in the bank decreased over the same period.",
|
||||||
|
],
|
||||||
|
unknownLabels: [
|
||||||
|
"Possible explanation for the contradiction from one side of the situation.",
|
||||||
|
"Possible explanation for the contradiction from another side of the situation.",
|
||||||
|
],
|
||||||
|
disallowedQuestionTerms: [
|
||||||
|
"accounts receivable",
|
||||||
|
"capex",
|
||||||
|
"debt repayments",
|
||||||
|
"working capital",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
buildAmbiguityFixture({
|
||||||
|
key: "satisfaction-complaints",
|
||||||
|
scenario:
|
||||||
|
"Customer satisfaction scores increased, but complaints also increased.",
|
||||||
|
summaryLabel: "Satisfaction scores rose while complaints also rose",
|
||||||
|
contradictionLabel:
|
||||||
|
"Contradiction between higher satisfaction scores and higher complaint volume.",
|
||||||
|
observationLabels: [
|
||||||
|
"Customer satisfaction scores increased.",
|
||||||
|
"Complaints increased.",
|
||||||
|
],
|
||||||
|
unknownLabels: [
|
||||||
|
"Possible explanation for why the positive signal and negative signal moved together.",
|
||||||
|
"Another possible explanation for why the positive signal and negative signal moved together.",
|
||||||
|
],
|
||||||
|
disallowedQuestionTerms: [
|
||||||
|
"net promoter",
|
||||||
|
"ticket backlog",
|
||||||
|
"call deflection",
|
||||||
|
"support queue",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
buildAmbiguityFixture({
|
||||||
|
key: "delivery-cancellations",
|
||||||
|
scenario:
|
||||||
|
"Average delivery time decreased by 25%, but order cancellations increased.",
|
||||||
|
summaryLabel: "Delivery became faster while cancellations increased",
|
||||||
|
contradictionLabel:
|
||||||
|
"Contradiction between faster delivery and more order cancellations.",
|
||||||
|
observationLabels: [
|
||||||
|
"Average delivery time decreased by 25%.",
|
||||||
|
"Order cancellations increased.",
|
||||||
|
],
|
||||||
|
unknownLabels: [
|
||||||
|
"Possible explanation for why the faster result did not reduce the negative result.",
|
||||||
|
"Another possible explanation for why the faster result did not reduce the negative result.",
|
||||||
|
],
|
||||||
|
disallowedQuestionTerms: [
|
||||||
|
"fulfilment",
|
||||||
|
"last mile",
|
||||||
|
"warehouse",
|
||||||
|
"routing",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
buildAmbiguityFixture({
|
||||||
|
key: "traffic-sales",
|
||||||
|
scenario: "Website traffic doubled, but sales remained unchanged.",
|
||||||
|
summaryLabel: "Website traffic doubled while sales stayed flat",
|
||||||
|
contradictionLabel:
|
||||||
|
"Contradiction between much higher traffic and unchanged sales.",
|
||||||
|
observationLabels: [
|
||||||
|
"Website traffic doubled.",
|
||||||
|
"Sales remained unchanged.",
|
||||||
|
],
|
||||||
|
unknownLabels: [
|
||||||
|
"Possible explanation for why the stronger signal did not change the outcome.",
|
||||||
|
"Another possible explanation for why the stronger signal did not change the outcome.",
|
||||||
|
],
|
||||||
|
disallowedQuestionTerms: [
|
||||||
|
"conversion funnel",
|
||||||
|
"campaign attribution",
|
||||||
|
"landing page",
|
||||||
|
"checkout flow",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
buildAmbiguityFixture({
|
||||||
|
key: "output-defects",
|
||||||
|
scenario:
|
||||||
|
"Production output increased by 30%, but quality defects also increased.",
|
||||||
|
summaryLabel: "Production output rose while defects also rose",
|
||||||
|
contradictionLabel:
|
||||||
|
"Contradiction between higher output and more quality defects.",
|
||||||
|
observationLabels: [
|
||||||
|
"Production output increased by 30%.",
|
||||||
|
"Quality defects increased.",
|
||||||
|
],
|
||||||
|
unknownLabels: [
|
||||||
|
"Possible explanation for why the gain came with a worsening result.",
|
||||||
|
"Another possible explanation for why the gain came with a worsening result.",
|
||||||
|
],
|
||||||
|
disallowedQuestionTerms: [
|
||||||
|
"scrap rate",
|
||||||
|
"throughput",
|
||||||
|
"yield",
|
||||||
|
"root cause",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function buildComparabilityFixture({
|
||||||
|
key,
|
||||||
|
scenario,
|
||||||
|
observationLabels,
|
||||||
|
contradictionLabel,
|
||||||
|
expectedComparabilityStatus,
|
||||||
|
expectsComparisonQuestion,
|
||||||
|
}) {
|
||||||
|
const summary = makeNode({
|
||||||
|
id: `${key}-summary`,
|
||||||
|
label: scenario,
|
||||||
|
description: "Summary of the situation from the scenario text",
|
||||||
|
kind: "state",
|
||||||
|
status: "provisional",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const observations = observationLabels.map((label, index) =>
|
||||||
|
makeNode({
|
||||||
|
id: `${key}-obs-${index + 1}`,
|
||||||
|
label,
|
||||||
|
description: label,
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const contradiction = contradictionLabel
|
||||||
|
? [
|
||||||
|
makeNode({
|
||||||
|
id: `${key}-contradiction`,
|
||||||
|
label: contradictionLabel,
|
||||||
|
description: contradictionLabel,
|
||||||
|
kind: "relationship",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const unknowns = [
|
||||||
|
makeNode({
|
||||||
|
id: `${key}-unknown-a`,
|
||||||
|
label: "Possible explanation from one side of the situation.",
|
||||||
|
description: "Possible explanation from one side of the situation.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: `${key}-unknown-b`,
|
||||||
|
label: "Possible explanation from another side of the situation.",
|
||||||
|
description: "Possible explanation from another side of the situation.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
...observations.map((node) =>
|
||||||
|
makeEdge({
|
||||||
|
id: `${node.id}-supports-summary`,
|
||||||
|
fromNodeId: node.id,
|
||||||
|
toNodeId: summary.id,
|
||||||
|
relationship: "supports",
|
||||||
|
description: `${node.label} supports the summary.`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...unknowns.map((node) =>
|
||||||
|
makeEdge({
|
||||||
|
id: `${node.id}-depends-summary`,
|
||||||
|
fromNodeId: node.id,
|
||||||
|
toNodeId: summary.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${node.label} is an unresolved factor for this situation.`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
scenario,
|
||||||
|
expectedComparabilityStatus,
|
||||||
|
expectsComparisonQuestion,
|
||||||
|
graph: makeGraph({
|
||||||
|
centralStatement: scenario,
|
||||||
|
nodes: [summary, ...observations, ...contradiction, ...unknowns],
|
||||||
|
edges,
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: `Comparability fixture for ${key}`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const comparabilityAssessmentFixtures = [
|
||||||
|
buildComparabilityFixture({
|
||||||
|
key: "revenue-cash",
|
||||||
|
scenario:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
observationLabels: [
|
||||||
|
"Revenue increased by 18%.",
|
||||||
|
"Cash in the bank decreased over the same period.",
|
||||||
|
],
|
||||||
|
contradictionLabel:
|
||||||
|
"Contradiction between revenue improvement and lower cash reserves.",
|
||||||
|
expectedComparabilityStatus: "uncertain",
|
||||||
|
expectsComparisonQuestion: true,
|
||||||
|
}),
|
||||||
|
buildComparabilityFixture({
|
||||||
|
key: "complaints-production",
|
||||||
|
scenario: "Complaints increased. Production increased.",
|
||||||
|
observationLabels: ["Complaints increased.", "Production increased."],
|
||||||
|
contradictionLabel:
|
||||||
|
"Possible contradiction between complaints and production movement.",
|
||||||
|
expectedComparabilityStatus: "uncertain",
|
||||||
|
expectsComparisonQuestion: true,
|
||||||
|
}),
|
||||||
|
buildComparabilityFixture({
|
||||||
|
key: "delivery-cancellations",
|
||||||
|
scenario:
|
||||||
|
"Average delivery time decreased by 25%, but order cancellations increased.",
|
||||||
|
observationLabels: [
|
||||||
|
"Average delivery time decreased by 25%.",
|
||||||
|
"Order cancellations increased.",
|
||||||
|
],
|
||||||
|
contradictionLabel:
|
||||||
|
"Contradiction between faster delivery and more cancellations.",
|
||||||
|
expectedComparabilityStatus: "uncertain",
|
||||||
|
expectsComparisonQuestion: true,
|
||||||
|
}),
|
||||||
|
buildComparabilityFixture({
|
||||||
|
key: "satisfaction-complaints",
|
||||||
|
scenario: "Customer satisfaction increased, but complaints increased.",
|
||||||
|
observationLabels: [
|
||||||
|
"Customer satisfaction increased.",
|
||||||
|
"Complaints increased.",
|
||||||
|
],
|
||||||
|
contradictionLabel:
|
||||||
|
"Contradiction between satisfaction improvement and more complaints.",
|
||||||
|
expectedComparabilityStatus: "uncertain",
|
||||||
|
expectsComparisonQuestion: true,
|
||||||
|
}),
|
||||||
|
buildComparabilityFixture({
|
||||||
|
key: "temperature-ice",
|
||||||
|
scenario: "Temperature increased. Ice melted.",
|
||||||
|
observationLabels: ["Temperature increased.", "Ice melted."],
|
||||||
|
contradictionLabel: null,
|
||||||
|
expectedComparabilityStatus: "confirmed",
|
||||||
|
expectsComparisonQuestion: false,
|
||||||
|
}),
|
||||||
|
buildComparabilityFixture({
|
||||||
|
key: "sales-same",
|
||||||
|
scenario: "Sales doubled. Sales doubled.",
|
||||||
|
observationLabels: ["Sales doubled.", "Sales doubled."],
|
||||||
|
contradictionLabel: null,
|
||||||
|
expectedComparabilityStatus: "confirmed",
|
||||||
|
expectsComparisonQuestion: false,
|
||||||
|
}),
|
||||||
|
];
|
||||||
+17
-5
@@ -67,7 +67,11 @@ export const questionPriorityGeneralisationFixtures = [
|
|||||||
"hire-bottleneck",
|
"hire-bottleneck",
|
||||||
],
|
],
|
||||||
prohibitedFirstTopics: ["salary", "job advert", "programming language"],
|
prohibitedFirstTopics: ["salary", "job advert", "programming language"],
|
||||||
acceptableQuestionStrategies: ["decision criterion", "constraint"],
|
acceptableQuestionStrategies: [
|
||||||
|
"decision_threshold",
|
||||||
|
"evidence_gathering",
|
||||||
|
"definition",
|
||||||
|
],
|
||||||
notes:
|
notes:
|
||||||
"The first question should establish whether more engineering capacity is justified before compensation or implementation details.",
|
"The first question should establish whether more engineering capacity is justified before compensation or implementation details.",
|
||||||
graph: makeScenarioGraph({
|
graph: makeScenarioGraph({
|
||||||
@@ -143,7 +147,11 @@ export const questionPriorityGeneralisationFixtures = [
|
|||||||
"paint colour",
|
"paint colour",
|
||||||
"finance provider",
|
"finance provider",
|
||||||
],
|
],
|
||||||
acceptableQuestionStrategies: ["decision criterion", "constraint"],
|
acceptableQuestionStrategies: [
|
||||||
|
"decision_threshold",
|
||||||
|
"evidence_gathering",
|
||||||
|
"definition",
|
||||||
|
],
|
||||||
notes:
|
notes:
|
||||||
"The first question should establish whether the fleet is failing a threshold that justifies replacement.",
|
"The first question should establish whether the fleet is failing a threshold that justifies replacement.",
|
||||||
graph: makeScenarioGraph({
|
graph: makeScenarioGraph({
|
||||||
@@ -219,7 +227,7 @@ export const questionPriorityGeneralisationFixtures = [
|
|||||||
"office location",
|
"office location",
|
||||||
"advertising channel",
|
"advertising channel",
|
||||||
],
|
],
|
||||||
acceptableQuestionStrategies: ["actor/customer", "decision criterion"],
|
acceptableQuestionStrategies: ["definition", "decision_threshold"],
|
||||||
notes:
|
notes:
|
||||||
"The first question should clarify the customer or value case for expansion before rollout logistics.",
|
"The first question should clarify the customer or value case for expansion before rollout logistics.",
|
||||||
graph: makeScenarioGraph({
|
graph: makeScenarioGraph({
|
||||||
@@ -291,7 +299,7 @@ export const questionPriorityGeneralisationFixtures = [
|
|||||||
"project-remaining-benefit",
|
"project-remaining-benefit",
|
||||||
],
|
],
|
||||||
prohibitedFirstTopics: ["sunk cost", "project logo", "final launch date"],
|
prohibitedFirstTopics: ["sunk cost", "project logo", "final launch date"],
|
||||||
acceptableQuestionStrategies: ["decision criterion", "objective"],
|
acceptableQuestionStrategies: ["decision_threshold", "definition"],
|
||||||
notes:
|
notes:
|
||||||
"The first question should establish remaining value or success threshold before sunk-cost framing or launch timing.",
|
"The first question should establish remaining value or success threshold before sunk-cost framing or launch timing.",
|
||||||
graph: makeScenarioGraph({
|
graph: makeScenarioGraph({
|
||||||
@@ -367,7 +375,11 @@ export const questionPriorityGeneralisationFixtures = [
|
|||||||
"payment provider",
|
"payment provider",
|
||||||
"tier name",
|
"tier name",
|
||||||
],
|
],
|
||||||
acceptableQuestionStrategies: ["actor/customer", "decision criterion"],
|
acceptableQuestionStrategies: [
|
||||||
|
"definition",
|
||||||
|
"decision_threshold",
|
||||||
|
"baseline_reconstruction",
|
||||||
|
],
|
||||||
notes:
|
notes:
|
||||||
"The first question should establish who values paid support or what outcome would justify offering it before pricing details.",
|
"The first question should establish who values paid support or what outcome would justify offering it before pricing details.",
|
||||||
graph: makeScenarioGraph({
|
graph: makeScenarioGraph({
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
formulateQuestion,
|
||||||
|
formulateTieResolutionQuestion,
|
||||||
|
} from "@/lib/graph/question-formulator.js";
|
||||||
|
import {
|
||||||
|
explainUnknownSelection,
|
||||||
|
selectActiveUnknownCandidate,
|
||||||
|
} from "@/lib/graph/utils.js";
|
||||||
|
import { ambiguityGeneralisationFixtures } from "@/tests/fixtures/ambiguity-generalisation.js";
|
||||||
|
|
||||||
|
function neutraliseUnknownLabels(graph) {
|
||||||
|
let counter = 0;
|
||||||
|
return {
|
||||||
|
...graph,
|
||||||
|
nodes: graph.nodes.map((node) => {
|
||||||
|
if (node.kind !== "unknown") return { ...node };
|
||||||
|
counter += 1;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
label: `Unknown ${String.fromCharCode(64 + counter)}`,
|
||||||
|
description: `Unknown factor ${counter}.`,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSingleQuestion(question) {
|
||||||
|
return (question.match(/\?/g) || []).length === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ambiguity generalisation", () => {
|
||||||
|
it("preserves ambiguity across contradiction scenarios without favouring one explanation", () => {
|
||||||
|
const summary = ambiguityGeneralisationFixtures.map((fixture) => {
|
||||||
|
const explanation = explainUnknownSelection(fixture.graph, []);
|
||||||
|
const selection = selectActiveUnknownCandidate(fixture.graph, []);
|
||||||
|
const neutralExplanation = explainUnknownSelection(
|
||||||
|
neutraliseUnknownLabels(fixture.graph),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const tieQuestion = formulateTieResolutionQuestion({
|
||||||
|
graph: fixture.graph,
|
||||||
|
});
|
||||||
|
const representativeUnknown = fixture.graph.nodes.find(
|
||||||
|
(node) => node.kind === "unknown",
|
||||||
|
);
|
||||||
|
const fallbackQuestion = formulateQuestion({
|
||||||
|
node: representativeUnknown,
|
||||||
|
graph: fixture.graph,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lowerQuestion = tieQuestion.question.toLowerCase();
|
||||||
|
for (const term of fixture.disallowedQuestionTerms) {
|
||||||
|
expect(lowerQuestion).not.toContain(term.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(explanation.status).toBe("ambiguous");
|
||||||
|
expect(selection.status).toBe("ambiguous");
|
||||||
|
expect(selection.selectedNode).toBeNull();
|
||||||
|
expect(explanation.selectedNodeId).toBeNull();
|
||||||
|
expect(explanation.candidates).toHaveLength(2);
|
||||||
|
expect(explanation.summary.selectedReason).toBe(
|
||||||
|
"No justified distinction between leading unknowns.",
|
||||||
|
);
|
||||||
|
expect(explanation.alphabeticalUsedAsReasoning).toBe(false);
|
||||||
|
expect(neutralExplanation.status).toBe("ambiguous");
|
||||||
|
expect(isSingleQuestion(tieQuestion.question)).toBe(true);
|
||||||
|
expect(tieQuestion.question.toLowerCase()).not.toContain(" or ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
scenario: fixture.scenario,
|
||||||
|
candidateCount: explanation.candidates.length,
|
||||||
|
ambiguityStatus: explanation.status,
|
||||||
|
tieReason: explanation.summary.selectedReason,
|
||||||
|
investigationStrategy: tieQuestion.strategy,
|
||||||
|
question: tieQuestion.question,
|
||||||
|
explanationFavoured: explanation.selectedNodeId !== null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"ambiguityStatus": "ambiguous",
|
||||||
|
"candidateCount": 2,
|
||||||
|
"explanationFavoured": false,
|
||||||
|
"investigationStrategy": null,
|
||||||
|
"question": "Were these figures measured on the same basis and at the same scale?",
|
||||||
|
"scenario": "Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
"tieReason": "No justified distinction between leading unknowns.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ambiguityStatus": "ambiguous",
|
||||||
|
"candidateCount": 2,
|
||||||
|
"explanationFavoured": false,
|
||||||
|
"investigationStrategy": null,
|
||||||
|
"question": "Were these figures measured over the same period and at the same scale?",
|
||||||
|
"scenario": "Customer satisfaction scores increased, but complaints also increased.",
|
||||||
|
"tieReason": "No justified distinction between leading unknowns.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ambiguityStatus": "ambiguous",
|
||||||
|
"candidateCount": 2,
|
||||||
|
"explanationFavoured": false,
|
||||||
|
"investigationStrategy": null,
|
||||||
|
"question": "Were these figures measured over the same period and at the same scale?",
|
||||||
|
"scenario": "Average delivery time decreased by 25%, but order cancellations increased.",
|
||||||
|
"tieReason": "No justified distinction between leading unknowns.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ambiguityStatus": "ambiguous",
|
||||||
|
"candidateCount": 2,
|
||||||
|
"explanationFavoured": false,
|
||||||
|
"investigationStrategy": null,
|
||||||
|
"question": "Were these figures measured over the same period and at the same scale?",
|
||||||
|
"scenario": "Website traffic doubled, but sales remained unchanged.",
|
||||||
|
"tieReason": "No justified distinction between leading unknowns.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ambiguityStatus": "ambiguous",
|
||||||
|
"candidateCount": 2,
|
||||||
|
"explanationFavoured": false,
|
||||||
|
"investigationStrategy": null,
|
||||||
|
"question": "Were these figures measured over the same period and at the same scale?",
|
||||||
|
"scenario": "Production output increased by 30%, but quality defects also increased.",
|
||||||
|
"tieReason": "No justified distinction between leading unknowns.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,120 @@ import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
|||||||
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
import { validateGraphReferences } from "@/lib/graph/utils.js";
|
import { validateGraphReferences } from "@/lib/graph/utils.js";
|
||||||
|
|
||||||
|
function makeComparabilityUpdateFixture() {
|
||||||
|
const comparabilityUnknown = makeNode({
|
||||||
|
id: "n-comparability-unknown",
|
||||||
|
label: "Whether the figures are comparable",
|
||||||
|
description:
|
||||||
|
"Need to know whether the figures use the same period, basis, and scale before comparing them.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const revenueObservation = makeNode({
|
||||||
|
id: "n-revenue-observation",
|
||||||
|
label: "Revenue increased by 18%.",
|
||||||
|
description: "Revenue increased by 18%.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const cashObservation = makeNode({
|
||||||
|
id: "n-cash-observation",
|
||||||
|
label: "Cash in the bank decreased over the same period.",
|
||||||
|
description: "Cash in the bank decreased over the same period.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const unrelatedNode = makeNode({
|
||||||
|
id: "n-unrelated",
|
||||||
|
label: "Board update",
|
||||||
|
description: "A separate unchanged note.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "low",
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
nodes: [
|
||||||
|
comparabilityUnknown,
|
||||||
|
revenueObservation,
|
||||||
|
cashObservation,
|
||||||
|
unrelatedNode,
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-revenue-comparability",
|
||||||
|
fromNodeId: revenueObservation.id,
|
||||||
|
toNodeId: comparabilityUnknown.id,
|
||||||
|
relationship: "supports",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Revenue observation requires comparability confirmation.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-cash-comparability",
|
||||||
|
fromNodeId: cashObservation.id,
|
||||||
|
toNodeId: comparabilityUnknown.id,
|
||||||
|
relationship: "supports",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Cash observation requires comparability confirmation.",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
activeUnknownNodeId: comparabilityUnknown.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Initial comparability fixture",
|
||||||
|
reasoningState: {
|
||||||
|
comparabilityStatus: "uncertain",
|
||||||
|
comparabilityReason:
|
||||||
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
||||||
|
comparabilityEvidence: [],
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
relationshipReason:
|
||||||
|
"Relationship classification is deferred until comparability is established.",
|
||||||
|
relationshipAssessed: false,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
reasoningStages: [
|
||||||
|
{
|
||||||
|
stage: "comparability",
|
||||||
|
status: "uncertain",
|
||||||
|
outcome:
|
||||||
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "relationship",
|
||||||
|
status: "insufficient_information",
|
||||||
|
outcome: "not assessed until comparability is established",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: comparabilityUnknown.id,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue:
|
||||||
|
"Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||||
|
reason: "The answer confirms the figures are comparable.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [comparabilityUnknown.id],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { graph, proposal, comparabilityUnknownId: comparabilityUnknown.id };
|
||||||
|
}
|
||||||
|
|
||||||
function makeApplicationFixture() {
|
function makeApplicationFixture() {
|
||||||
const complaintRateUnknown = makeNode({
|
const complaintRateUnknown = makeNode({
|
||||||
id: "n-complaint-rate-unknown",
|
id: "n-complaint-rate-unknown",
|
||||||
@@ -997,4 +1111,211 @@ describe("applyValidatedProposal", () => {
|
|||||||
"price",
|
"price",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves the existing comparability unknown and advances reasoning after the answer", () => {
|
||||||
|
const { graph, proposal, comparabilityUnknownId } =
|
||||||
|
makeComparabilityUpdateFixture();
|
||||||
|
const originalUnrelatedNode = JSON.stringify(
|
||||||
|
graph.nodes.find((node) => node.id === "n-unrelated"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
previousQuestion:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
answer:
|
||||||
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.resolvedUnknownNodeIds).toContain(comparabilityUnknownId);
|
||||||
|
expect(result.resolvedReasoningNodeIds).toEqual([
|
||||||
|
"reasoning:comparability",
|
||||||
|
]);
|
||||||
|
expect(result.emergentReasoningNodeCreated).toBe(true);
|
||||||
|
expect(result.emergentReasoningNodeId).toBeTruthy();
|
||||||
|
expect(result.emergentReasoningNodeReason).toContain("backed by the graph");
|
||||||
|
expect(result.previousReasoningState.comparabilityStatus).toBe("uncertain");
|
||||||
|
expect(result.reasoningState).toMatchObject({
|
||||||
|
comparabilityStatus: "confirmed",
|
||||||
|
relationshipStatus: "potentially_related",
|
||||||
|
relationshipAssessed: true,
|
||||||
|
});
|
||||||
|
expect(result.reasoningState.comparabilityEvidence).toEqual([
|
||||||
|
comparabilityUnknownId,
|
||||||
|
]);
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
||||||
|
expect(result.selectedQuestion).toMatchObject({
|
||||||
|
nodeId: result.newActiveUnknownNodeId,
|
||||||
|
question:
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
});
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
||||||
|
/dso|debtor days|receivables turnover|working capital|receivables/,
|
||||||
|
);
|
||||||
|
expect(result.reasoningState.reasoningStages).toEqual([
|
||||||
|
{
|
||||||
|
stage: "comparability",
|
||||||
|
status: "confirmed",
|
||||||
|
outcome:
|
||||||
|
"Comparability was confirmed by the user answer covering the same period and source basis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "relationship",
|
||||||
|
status: "potentially_related",
|
||||||
|
outcome:
|
||||||
|
"The observations concern connected business signals but do not establish a direct contradiction or cause.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const emergentNode = result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === result.emergentReasoningNodeId,
|
||||||
|
);
|
||||||
|
expect(emergentNode).toMatchObject({
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
expect(emergentNode.description.toLowerCase()).toContain("because");
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.edges.filter(
|
||||||
|
(edge) => edge.toNodeId === result.emergentReasoningNodeId,
|
||||||
|
),
|
||||||
|
).not.toEqual([]);
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.edges.some(
|
||||||
|
(edge) =>
|
||||||
|
edge.toNodeId === result.emergentReasoningNodeId &&
|
||||||
|
edge.relationship === "causes",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
JSON.stringify(
|
||||||
|
result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === "n-unrelated",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(originalUnrelatedNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses an equivalent existing unresolved reasoning unknown instead of creating a duplicate", () => {
|
||||||
|
const { graph, proposal } = makeComparabilityUpdateFixture();
|
||||||
|
graph.nodes.push(
|
||||||
|
makeNode({
|
||||||
|
id: "n-existing-explanation",
|
||||||
|
label:
|
||||||
|
"Explanation for why Revenue increased by 18%, but cash in the bank fell over the same period",
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
previousQuestion:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
answer:
|
||||||
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.emergentReasoningNodeCreated).toBe(false);
|
||||||
|
expect(result.emergentReasoningNodeId).toBe("n-existing-explanation");
|
||||||
|
expect(result.newActiveUnknownNodeId).not.toBe("n-existing-explanation");
|
||||||
|
expect(result.selectedQuestion?.nodeId).not.toBe("n-existing-explanation");
|
||||||
|
expect(result.selectedQuestion?.question).toBe(
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.nodes.filter(
|
||||||
|
(node) => node.label === graph.nodes.at(-1).label,
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decomposes a composite selected unknown before asking the next question", () => {
|
||||||
|
const { graph, proposal } = makeComparabilityUpdateFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
previousQuestion:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
answer:
|
||||||
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.atomicityAssessment).toBe("composite");
|
||||||
|
expect(result.decompositionPerformed).toBe(true);
|
||||||
|
expect(result.childUnknownCount).toBe(5);
|
||||||
|
expect(result.childNodeIds).toHaveLength(5);
|
||||||
|
expect(result.atomicityReason).toBeTruthy();
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
||||||
|
expect(result.selectedQuestion?.nodeId).not.toBe(
|
||||||
|
result.emergentReasoningNodeId,
|
||||||
|
);
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
||||||
|
/dso|working capital|receivables|capex/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentNode = result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === result.emergentReasoningNodeId,
|
||||||
|
);
|
||||||
|
expect(parentNode?.status).toBe("unknown");
|
||||||
|
|
||||||
|
const childNodes = result.updatedSituationGraph.nodes.filter((node) =>
|
||||||
|
result.childNodeIds.includes(node.id),
|
||||||
|
);
|
||||||
|
expect(childNodes).toHaveLength(5);
|
||||||
|
expect(childNodes.every((node) => node.parentId === parentNode.id)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.edges.filter(
|
||||||
|
(edge) =>
|
||||||
|
result.childNodeIds.includes(edge.fromNodeId) &&
|
||||||
|
edge.toNodeId === parentNode.id &&
|
||||||
|
edge.relationship === "depends_on",
|
||||||
|
),
|
||||||
|
).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses existing decomposition children instead of duplicating them", () => {
|
||||||
|
const { graph, proposal } = makeComparabilityUpdateFixture();
|
||||||
|
|
||||||
|
const firstResult = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
previousQuestion:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
answer:
|
||||||
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstResult.success).toBe(true);
|
||||||
|
|
||||||
|
const secondResult = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
previousQuestion:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
answer:
|
||||||
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondResult.success).toBe(true);
|
||||||
|
expect(secondResult.atomicityAssessment).toBe("composite");
|
||||||
|
const uniqueChildIds = new Set(firstResult.childNodeIds);
|
||||||
|
expect(uniqueChildIds.size).toBe(firstResult.childNodeIds.length);
|
||||||
|
expect(
|
||||||
|
secondResult.updatedSituationGraph.nodes.filter((node) =>
|
||||||
|
firstResult.childNodeIds.includes(node.id),
|
||||||
|
),
|
||||||
|
).toHaveLength(firstResult.childNodeIds.length);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { assessUnknownAtomicity } from "@/lib/graph/question-formulator.js";
|
||||||
|
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function makeGraphWithUnknown(centralStatement, unknown, observations = []) {
|
||||||
|
return makeGraph({
|
||||||
|
centralStatement,
|
||||||
|
nodes: [unknown, ...observations],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: unknown.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Atomicity test graph",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assessUnknownAtomicity", () => {
|
||||||
|
it("classifies denominator-style unknowns as atomic", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-denominator",
|
||||||
|
label: "Complaint rate denominator",
|
||||||
|
description:
|
||||||
|
"Need the denominator because it directly determines the complaint rate.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = assessUnknownAtomicity({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphWithUnknown(
|
||||||
|
"Production increased while complaints increased.",
|
||||||
|
unknown,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.atomicity).toBe("atomic");
|
||||||
|
expect(result.reason.toLowerCase()).toContain("directly");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies relationship explanation unknowns as composite", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-explanation",
|
||||||
|
label:
|
||||||
|
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = makeGraphWithUnknown(
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
unknown,
|
||||||
|
[
|
||||||
|
makeNode({
|
||||||
|
id: "n-revenue",
|
||||||
|
label: "Revenue increased by 18%.",
|
||||||
|
description: "Revenue increased by 18%.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-cash",
|
||||||
|
label: "Cash in the bank decreased over the same period.",
|
||||||
|
description: "Cash in the bank decreased over the same period.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = assessUnknownAtomicity({ node: unknown, graph });
|
||||||
|
|
||||||
|
expect(result.atomicity).toBe("composite");
|
||||||
|
expect(result.decompositionKind).toBe("relationship_explanation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"Customer satisfaction rose, but complaints also rose.",
|
||||||
|
"Explanation for why customer satisfaction rose, but complaints also rose",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Delivery time fell, but cancellations increased.",
|
||||||
|
"Possible causes of why delivery time fell, but cancellations increased",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Traffic increased, but sales stayed flat.",
|
||||||
|
"Broad explanation for why traffic increased, but sales stayed flat",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Production increased, but defects also increased.",
|
||||||
|
"Factors behind why production increased, but defects also increased",
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"classifies broad divergence unknowns as composite: %s",
|
||||||
|
(scenario, label) => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: `n-${label.length}`,
|
||||||
|
label,
|
||||||
|
description: `${label} because the current unknown is too broad to ask directly.`,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = assessUnknownAtomicity({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphWithUnknown(scenario, unknown),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.atomicity).toBe("composite");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assessComparability,
|
||||||
|
classifyObservationRelationship,
|
||||||
|
formulateTieResolutionQuestion,
|
||||||
|
} from "@/lib/graph/question-formulator.js";
|
||||||
|
import { explainUnknownSelection } from "@/lib/graph/utils.js";
|
||||||
|
import { comparabilityAssessmentFixtures } from "@/tests/fixtures/comparability-assessment.js";
|
||||||
|
|
||||||
|
describe("comparability assessment", () => {
|
||||||
|
it("generates comparison or relationship questions only when warranted", () => {
|
||||||
|
const summary = comparabilityAssessmentFixtures.map((fixture) => {
|
||||||
|
const assessment = assessComparability(fixture.graph);
|
||||||
|
const relationship = classifyObservationRelationship(fixture.graph);
|
||||||
|
const question = formulateTieResolutionQuestion({ graph: fixture.graph });
|
||||||
|
const ambiguity = explainUnknownSelection(fixture.graph, []);
|
||||||
|
|
||||||
|
expect(assessment.comparabilityStatus).toBe(
|
||||||
|
fixture.expectedComparabilityStatus,
|
||||||
|
);
|
||||||
|
expect(question.comparabilityStatus).toBe(
|
||||||
|
fixture.expectedComparabilityStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fixture.expectsComparisonQuestion) {
|
||||||
|
expect(question.question.toLowerCase()).toContain("same");
|
||||||
|
expect(question.contradictionReasoningAllowed).toBe(false);
|
||||||
|
} else {
|
||||||
|
expect(question.question?.toLowerCase() || "").not.toContain(
|
||||||
|
"same period and at the same scale",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixture.key !== "sales-same") {
|
||||||
|
expect(ambiguity.status).toBe("ambiguous");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scenario: fixture.scenario,
|
||||||
|
comparabilityStatus: assessment.comparabilityStatus,
|
||||||
|
relationshipStatus: relationship.relationshipStatus,
|
||||||
|
relationshipAssessed: relationship.relationshipAssessed,
|
||||||
|
contradictionReasoningAllowed: question.contradictionReasoningAllowed,
|
||||||
|
question: question.question,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toEqual([
|
||||||
|
{
|
||||||
|
scenario:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
comparabilityStatus: "uncertain",
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
relationshipAssessed: false,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
question:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "Complaints increased. Production increased.",
|
||||||
|
comparabilityStatus: "uncertain",
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
relationshipAssessed: false,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
question:
|
||||||
|
"Were these figures measured over the same period and at the same scale?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario:
|
||||||
|
"Average delivery time decreased by 25%, but order cancellations increased.",
|
||||||
|
comparabilityStatus: "uncertain",
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
relationshipAssessed: false,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
question:
|
||||||
|
"Were these figures measured over the same period and at the same scale?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "Customer satisfaction increased, but complaints increased.",
|
||||||
|
comparabilityStatus: "uncertain",
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
relationshipAssessed: false,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
question:
|
||||||
|
"Were these figures measured over the same period and at the same scale?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "Temperature increased. Ice melted.",
|
||||||
|
comparabilityStatus: "confirmed",
|
||||||
|
relationshipStatus: "compatible",
|
||||||
|
relationshipAssessed: true,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
question: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "Sales doubled. Sales doubled.",
|
||||||
|
comparabilityStatus: "confirmed",
|
||||||
|
relationshipStatus: "duplicate",
|
||||||
|
relationshipAssessed: true,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
question: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defers relationship classification while comparability is uncertain", () => {
|
||||||
|
const fixture = comparabilityAssessmentFixtures[0];
|
||||||
|
const relationship = classifyObservationRelationship(fixture.graph);
|
||||||
|
|
||||||
|
expect(relationship).toMatchObject({
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
relationshipAssessed: false,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
questionRequired: true,
|
||||||
|
});
|
||||||
|
expect(relationship.reasoningStages).toEqual([
|
||||||
|
{
|
||||||
|
stage: "comparability",
|
||||||
|
status: "uncertain",
|
||||||
|
outcome:
|
||||||
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "relationship",
|
||||||
|
status: "insufficient_information",
|
||||||
|
outcome: "not assessed until comparability is established",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows contradiction reasoning only for genuine contradictions", () => {
|
||||||
|
const serviceGraph = {
|
||||||
|
centralStatement:
|
||||||
|
"The service was reported as available throughout the hour and unavailable throughout the same hour.",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "service-available",
|
||||||
|
label: "The service was available throughout the hour.",
|
||||||
|
description: "The service was available throughout the hour.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
value: null,
|
||||||
|
unit: null,
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
parentId: null,
|
||||||
|
childIds: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service-unavailable",
|
||||||
|
label: "The service was unavailable throughout the same hour.",
|
||||||
|
description: "The service was unavailable throughout the same hour.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
value: null,
|
||||||
|
unit: null,
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
parentId: null,
|
||||||
|
childIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Service contradiction fixture",
|
||||||
|
};
|
||||||
|
const relationship = classifyObservationRelationship(serviceGraph);
|
||||||
|
|
||||||
|
expect(relationship).toMatchObject({
|
||||||
|
relationshipStatus: "contradictory",
|
||||||
|
contradictionReasoningAllowed: true,
|
||||||
|
questionRequired: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function makeFixture() {
|
||||||
|
const parent = makeNode({
|
||||||
|
id: "n-parent",
|
||||||
|
label: "Explanation for why revenue increased while cash fell",
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
const children = [
|
||||||
|
makeNode({
|
||||||
|
id: "n-child-1",
|
||||||
|
label: "How the two observations were measured",
|
||||||
|
description: "Need evidence about the measure used for each observation.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-child-2",
|
||||||
|
label: "Whether the two observations reflect different timing",
|
||||||
|
description:
|
||||||
|
"Need to know whether the two observations reflect different timing.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-child-3",
|
||||||
|
label: "Possible change mainly affecting revenue",
|
||||||
|
description:
|
||||||
|
"Need to know whether a possible change mainly affected revenue.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-child-4",
|
||||||
|
label: "Possible one-off event during the period",
|
||||||
|
description:
|
||||||
|
"Need to know whether a possible one-off event happened during the period.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return makeGraph({
|
||||||
|
centralStatement: "Revenue increased while cash fell.",
|
||||||
|
nodes: [parent, ...children],
|
||||||
|
edges: children.map((child, index) =>
|
||||||
|
makeEdge({
|
||||||
|
id: `e-${index + 1}`,
|
||||||
|
fromNodeId: child.id,
|
||||||
|
toNodeId: parent.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${child.label} feeds the parent.`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
activeUnknownNodeId: "n-child-1",
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "confidence propagation fixture",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProposal({ resolvedIds, contradictedIds = [] }) {
|
||||||
|
return {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-anchor",
|
||||||
|
label: "Update anchor",
|
||||||
|
description:
|
||||||
|
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "low",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
...resolvedIds.map((id) => ({
|
||||||
|
nodeId: id,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: `answer:${id}`,
|
||||||
|
reason: "resolved child",
|
||||||
|
})),
|
||||||
|
...contradictedIds.map((id) => ({
|
||||||
|
nodeId: id,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "contradicted",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: `contradiction:${id}`,
|
||||||
|
reason: "contradictory child evidence",
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: resolvedIds,
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("confidence propagation", () => {
|
||||||
|
it("one of four children resolved does not yield high conclusion confidence", () => {
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: makeFixture(),
|
||||||
|
proposal: makeProposal({ resolvedIds: ["n-child-1"] }),
|
||||||
|
previousQuestion:
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
answer: "Same accounting period and same management accounts.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent = result.updatedSituationGraph.nodes.find(
|
||||||
|
(n) => n.id === "n-parent",
|
||||||
|
);
|
||||||
|
expect(parent.status).toBe("provisional");
|
||||||
|
expect(parent.confidence).toBe("medium");
|
||||||
|
expect(parent.confidenceAssessment).toEqual({
|
||||||
|
evidenceConfidence: "medium",
|
||||||
|
completenessStatus: "partial",
|
||||||
|
conclusionConfidence: "medium",
|
||||||
|
});
|
||||||
|
expect(result.confidenceCapReason).toBe(
|
||||||
|
"unresolved_direct_children_cap_conclusion",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all children resolved with coherent evidence may yield high confidence", () => {
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: makeFixture(),
|
||||||
|
proposal: makeProposal({
|
||||||
|
resolvedIds: ["n-child-1", "n-child-2", "n-child-3", "n-child-4"],
|
||||||
|
}),
|
||||||
|
previousQuestion:
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
answer: "All direct child questions are answered.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent = result.updatedSituationGraph.nodes.find(
|
||||||
|
(n) => n.id === "n-parent",
|
||||||
|
);
|
||||||
|
expect(parent.status).toBe("resolved");
|
||||||
|
expect(parent.confidenceAssessment).toEqual({
|
||||||
|
evidenceConfidence: "high",
|
||||||
|
completenessStatus: "complete",
|
||||||
|
conclusionConfidence: "high",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contradictory child evidence prevents high confidence", () => {
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: makeFixture(),
|
||||||
|
proposal: makeProposal({
|
||||||
|
resolvedIds: ["n-child-1"],
|
||||||
|
contradictedIds: ["n-child-2"],
|
||||||
|
}),
|
||||||
|
previousQuestion:
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
answer: "One child resolved, another contradicted.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent = result.updatedSituationGraph.nodes.find(
|
||||||
|
(n) => n.id === "n-parent",
|
||||||
|
);
|
||||||
|
expect(parent.confidenceAssessment.conclusionConfidence).toBe("low");
|
||||||
|
expect(result.confidenceCapReason).toBe("contradictory_direct_children");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyValidatedProposal,
|
||||||
|
evaluateBranchInteractions,
|
||||||
|
} from "@/lib/graph/apply-proposal.js";
|
||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function makeParentWithBranches(children) {
|
||||||
|
const parent = makeNode({
|
||||||
|
id: "n-parent",
|
||||||
|
label: "Explanation for why revenue increased while cash fell",
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
return makeGraph({
|
||||||
|
centralStatement: "Revenue increased while cash fell.",
|
||||||
|
nodes: [
|
||||||
|
parent,
|
||||||
|
...children.map((child) => ({ ...child, parentId: parent.id })),
|
||||||
|
],
|
||||||
|
edges: children.map((child, index) =>
|
||||||
|
makeEdge({
|
||||||
|
id: `e-${index + 1}`,
|
||||||
|
fromNodeId: child.id,
|
||||||
|
toNodeId: parent.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: `${child.label} feeds the parent.`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
activeUnknownNodeId: children[0]?.id ?? null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "cross-branch corroboration fixture",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResolvedChild(id, label, value, extra = {}) {
|
||||||
|
return makeNode({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description: label,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "resolved",
|
||||||
|
confidence: "medium",
|
||||||
|
value,
|
||||||
|
evidenceIds: extra.evidenceIds ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUnknownBranch(id, label, description, extra = {}) {
|
||||||
|
return makeNode({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
kind: "unknown",
|
||||||
|
status: extra.status ?? "unknown",
|
||||||
|
confidence: extra.confidence ?? "medium",
|
||||||
|
evidenceIds: extra.evidenceIds ?? [],
|
||||||
|
value: extra.value ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("evaluateBranchInteractions", () => {
|
||||||
|
it("detects corroborating independent branches", () => {
|
||||||
|
const graph = makeParentWithBranches([
|
||||||
|
makeResolvedChild("n-a", "Debtor balance increased", "bank-statement-a", {
|
||||||
|
evidenceIds: ["bank-statement-a"],
|
||||||
|
}),
|
||||||
|
makeResolvedChild(
|
||||||
|
"n-b",
|
||||||
|
"Cash receipts were delayed",
|
||||||
|
"receipts-ledger-b",
|
||||||
|
{ evidenceIds: ["receipts-ledger-b"] },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const parentNode = graph.nodes.find((node) => node.id === "n-parent");
|
||||||
|
|
||||||
|
const result = evaluateBranchInteractions({ parentNode, graph });
|
||||||
|
|
||||||
|
expect(result.interactionSummary.corroboratingBranchCount).toBe(1);
|
||||||
|
expect(result.interactionSummary.duplicateEvidenceCount).toBe(0);
|
||||||
|
expect(result.interactionSummary.conflictingBranchCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects duplicate evidence instead of corroboration", () => {
|
||||||
|
const graph = makeParentWithBranches([
|
||||||
|
makeResolvedChild(
|
||||||
|
"n-a",
|
||||||
|
"Bank statement shows increased debtor balance",
|
||||||
|
"same-bank",
|
||||||
|
{
|
||||||
|
evidenceIds: ["same-bank"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
makeResolvedChild(
|
||||||
|
"n-b",
|
||||||
|
"Delayed receipts also cite the bank statement",
|
||||||
|
"same-bank",
|
||||||
|
{
|
||||||
|
evidenceIds: ["same-bank"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const parentNode = graph.nodes.find((node) => node.id === "n-parent");
|
||||||
|
|
||||||
|
const result = evaluateBranchInteractions({ parentNode, graph });
|
||||||
|
|
||||||
|
expect(result.interactionSummary.duplicateEvidenceCount).toBe(1);
|
||||||
|
expect(result.interactionSummary.corroboratingBranchCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects conflicting branches", () => {
|
||||||
|
const graph = makeParentWithBranches([
|
||||||
|
makeResolvedChild("n-a", "Revenue recognised correctly", "correctly"),
|
||||||
|
makeNode({
|
||||||
|
id: "n-b",
|
||||||
|
label: "Revenue recognised incorrectly",
|
||||||
|
description: "Revenue recognised incorrectly",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "contradicted",
|
||||||
|
confidence: "medium",
|
||||||
|
value: "incorrectly",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const parentNode = graph.nodes.find((node) => node.id === "n-parent");
|
||||||
|
|
||||||
|
const result = evaluateBranchInteractions({ parentNode, graph });
|
||||||
|
|
||||||
|
expect(result.interactionSummary.conflictingBranchCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cross-branch corroboration effects", () => {
|
||||||
|
function applyToGraph(children, resolvedIds, contradictedIds = []) {
|
||||||
|
const graph = makeParentWithBranches(children);
|
||||||
|
return applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-anchor",
|
||||||
|
label: "Update anchor",
|
||||||
|
description:
|
||||||
|
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "low",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
...resolvedIds.map((id) => ({
|
||||||
|
nodeId: id,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: `answer:${id}`,
|
||||||
|
reason: "resolved child",
|
||||||
|
})),
|
||||||
|
...contradictedIds.map((id) => ({
|
||||||
|
nodeId: id,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "contradicted",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: `contradiction:${id}`,
|
||||||
|
reason: "contradicted child",
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: resolvedIds,
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
|
},
|
||||||
|
previousQuestion: "What evidence would clarify this branch?",
|
||||||
|
answer: "deterministic branch update",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("independent corroboration increases justified confidence without reaching high on incomplete parent", () => {
|
||||||
|
const result = applyToGraph(
|
||||||
|
[
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-a",
|
||||||
|
"Debtor balance increased",
|
||||||
|
"Debtor balance increased",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-b",
|
||||||
|
"Cash receipts delayed",
|
||||||
|
"Cash receipts delayed",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-c",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-d",
|
||||||
|
"Whether the two observations reflect different timing",
|
||||||
|
"Whether the two observations reflect different timing",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["n-a", "n-b"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.interactionSummary?.corroboratingBranchCount).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(result.interactionSummary?.duplicateEvidenceCount).toBe(0);
|
||||||
|
expect(result.parentConfidenceAfter).toBe("medium");
|
||||||
|
expect(result.confidenceCapReason).toBe(
|
||||||
|
"independent_corroboration_with_incomplete_parent",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicate evidence does not increase confidence", () => {
|
||||||
|
const result = applyToGraph(
|
||||||
|
[
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-a",
|
||||||
|
"Bank statement shows increased debtor balance",
|
||||||
|
"Bank statement shows increased debtor balance",
|
||||||
|
{ evidenceIds: ["same-bank"] },
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-b",
|
||||||
|
"Delayed receipts also cite the bank statement",
|
||||||
|
"Delayed receipts also cite the bank statement",
|
||||||
|
{ evidenceIds: ["same-bank"] },
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-c",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["n-a", "n-b"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.interactionSummary?.duplicateEvidenceCount).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(result.interactionSummary?.corroboratingBranchCount).toBe(0);
|
||||||
|
expect(result.confidenceCapReason).toBe(
|
||||||
|
"duplicate_evidence_no_extra_confidence",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conflicting evidence caps confidence", () => {
|
||||||
|
const result = applyToGraph(
|
||||||
|
[
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-a",
|
||||||
|
"Revenue recognised correctly",
|
||||||
|
"Revenue recognised correctly",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-b",
|
||||||
|
"Revenue recognised incorrectly",
|
||||||
|
"Revenue recognised incorrectly",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-c",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
"Possible one-off event during the period",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["n-a"],
|
||||||
|
["n-b"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.interactionSummary?.conflictingBranchCount).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(result.conclusionConfidenceAfter).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("independent branches stay interaction-neutral", () => {
|
||||||
|
const result = applyToGraph(
|
||||||
|
[
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-a",
|
||||||
|
"Marketing campaign changed traffic",
|
||||||
|
"Marketing campaign changed traffic",
|
||||||
|
),
|
||||||
|
makeUnknownBranch(
|
||||||
|
"n-b",
|
||||||
|
"Equipment maintenance occurred",
|
||||||
|
"Equipment maintenance occurred",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["n-a"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.interactionSummary?.independentBranchCount).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assessChildUnknownQuality,
|
||||||
|
applyValidatedProposal,
|
||||||
|
MAX_DECOMPOSITION_DEPTH,
|
||||||
|
} from "@/lib/graph/apply-proposal.js";
|
||||||
|
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function makeParentGraph({
|
||||||
|
centralStatement,
|
||||||
|
parentLabel,
|
||||||
|
parentDescription,
|
||||||
|
observations = [],
|
||||||
|
}) {
|
||||||
|
const parent = makeNode({
|
||||||
|
id: "n-parent",
|
||||||
|
label: parentLabel,
|
||||||
|
description: parentDescription,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
parent,
|
||||||
|
graph: makeGraph({
|
||||||
|
centralStatement,
|
||||||
|
nodes: [parent, ...observations],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: parent.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Decomposition quality graph",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assessChildUnknownQuality", () => {
|
||||||
|
it("rejects 'Timing or measurement basis' as compound", () => {
|
||||||
|
const { parent, graph } = makeParentGraph({
|
||||||
|
centralStatement:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
parentLabel:
|
||||||
|
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||||
|
parentDescription:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
|
});
|
||||||
|
const child = makeNode({
|
||||||
|
id: "n-child",
|
||||||
|
label: "Timing or measurement basis",
|
||||||
|
description:
|
||||||
|
"Need evidence about whether a timing or measurement-basis difference could explain the observations, because that would change how they should be interpreted.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = assessChildUnknownQuality({
|
||||||
|
parentNode: parent,
|
||||||
|
childNode: child,
|
||||||
|
siblingNodes: [child],
|
||||||
|
graph,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.compoundSignals).toContain("timing_or_measurement_basis");
|
||||||
|
expect(result.reasons).toContain("compound_child");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a child with one directly answerable uncertainty", () => {
|
||||||
|
const { parent, graph } = makeParentGraph({
|
||||||
|
centralStatement: "Traffic increased, but sales stayed flat.",
|
||||||
|
parentLabel:
|
||||||
|
"What explains why more website traffic did not produce more sales?",
|
||||||
|
parentDescription:
|
||||||
|
"Need an explanation because the observations moved differently.",
|
||||||
|
});
|
||||||
|
const child = makeNode({
|
||||||
|
id: "n-child",
|
||||||
|
label: "Different measurement basis between the two observations",
|
||||||
|
description:
|
||||||
|
"Need evidence about whether the two observations use different measurement bases, because that could help explain the difference.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = assessChildUnknownQuality({
|
||||||
|
parentNode: parent,
|
||||||
|
childNode: child,
|
||||||
|
siblingNodes: [child],
|
||||||
|
graph,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.atomic).toBe(true);
|
||||||
|
expect(result.directlyAnswerable).toBe(true);
|
||||||
|
expect(result.narrowerThanParent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects sibling duplicates", () => {
|
||||||
|
const { parent, graph } = makeParentGraph({
|
||||||
|
centralStatement: "Production increased, but defects also increased.",
|
||||||
|
parentLabel: "What explains why output and defects both increased?",
|
||||||
|
parentDescription:
|
||||||
|
"Need an explanation because both observations increased.",
|
||||||
|
});
|
||||||
|
const childA = makeNode({
|
||||||
|
id: "n-child-a",
|
||||||
|
label: "Different timing between the two observations",
|
||||||
|
description:
|
||||||
|
"Need evidence about whether the two observations reflect different timing, because that could help explain the difference.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
const childB = makeNode({
|
||||||
|
id: "n-child-b",
|
||||||
|
label: "Different timing between the two observations",
|
||||||
|
description:
|
||||||
|
"Need evidence about whether the two observations reflect different timing, because that could help explain the difference.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = assessChildUnknownQuality({
|
||||||
|
parentNode: parent,
|
||||||
|
childNode: childA,
|
||||||
|
siblingNodes: [childA, childB],
|
||||||
|
graph,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.duplicateSiblingIds).toContain("n-child-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects parent paraphrases", () => {
|
||||||
|
const { parent, graph } = makeParentGraph({
|
||||||
|
centralStatement:
|
||||||
|
"Customer satisfaction scores increased, but complaints also increased.",
|
||||||
|
parentLabel:
|
||||||
|
"What explains why satisfaction and complaints both increased?",
|
||||||
|
parentDescription:
|
||||||
|
"Need a broad explanation because the observations moved differently.",
|
||||||
|
});
|
||||||
|
const child = makeNode({
|
||||||
|
id: "n-child",
|
||||||
|
label: "What explains why satisfaction and complaints both increased?",
|
||||||
|
description:
|
||||||
|
"Need a broad explanation because the observations moved differently.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = assessChildUnknownQuality({
|
||||||
|
parentNode: parent,
|
||||||
|
childNode: child,
|
||||||
|
siblingNodes: [child],
|
||||||
|
graph,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.reasons).toContain("not_narrower_than_parent");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decomposition stopping conditions", () => {
|
||||||
|
function makeMeaningfulNoOpProposal() {
|
||||||
|
return {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-anchor",
|
||||||
|
label: "Update anchor",
|
||||||
|
description:
|
||||||
|
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "low",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not decompose an atomic selected unknown", () => {
|
||||||
|
const atomic = makeNode({
|
||||||
|
id: "n-atomic",
|
||||||
|
label: "Were both figures measured over the same accounting period?",
|
||||||
|
description:
|
||||||
|
"Need to know whether both figures cover the same accounting period because that determines whether they are directly comparable.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
nodes: [atomic],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: atomic.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Atomic selected node graph",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: makeMeaningfulNoOpProposal(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.decompositionAttempted).toBe(false);
|
||||||
|
expect(result.decompositionStoppedReason).toBe(
|
||||||
|
"Selected unknown is already atomic.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops once a directly answerable child is selected", () => {
|
||||||
|
const { parent, graph } = makeParentGraph({
|
||||||
|
centralStatement: "Traffic increased, but sales stayed flat.",
|
||||||
|
parentLabel:
|
||||||
|
"What explains why more website traffic did not produce more sales?",
|
||||||
|
parentDescription:
|
||||||
|
"Need an explanation because the observations moved differently.",
|
||||||
|
observations: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-traffic",
|
||||||
|
label: "Website traffic increased.",
|
||||||
|
description: "Website traffic increased.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-sales",
|
||||||
|
label: "Sales stayed flat.",
|
||||||
|
description: "Sales stayed flat.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: makeMeaningfulNoOpProposal(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.decompositionAttempted).toBe(true);
|
||||||
|
expect(result.decompositionAccepted).toBe(true);
|
||||||
|
expect(result.selectedQuestion).toMatchObject({
|
||||||
|
nodeId: expect.any(String),
|
||||||
|
question:
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
});
|
||||||
|
expect(result.selectedChildNodeId).toBe(result.selectedQuestion?.nodeId);
|
||||||
|
expect(result.decompositionStoppedReason).toBe(
|
||||||
|
"Selected child is atomic and directly answerable.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the configured maximum decomposition depth", () => {
|
||||||
|
expect(MAX_DECOMPOSITION_DEPTH).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(MAX_DECOMPOSITION_DEPTH).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -118,6 +118,90 @@ function makeProposal(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeComparabilityScenarioGraph() {
|
||||||
|
return makeGraph({
|
||||||
|
centralStatement:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
nodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-comparability-unknown",
|
||||||
|
label: "Whether the figures are comparable",
|
||||||
|
description:
|
||||||
|
"Need to know whether the figures use the same period, basis, and scale before comparing them.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-revenue-observation",
|
||||||
|
label: "Revenue increased by 18%.",
|
||||||
|
description: "Revenue increased by 18%.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-cash-observation",
|
||||||
|
label: "Cash in the bank decreased over the same period.",
|
||||||
|
description: "Cash in the bank decreased over the same period.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: "n-comparability-unknown",
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Comparability scenario",
|
||||||
|
reasoningState: {
|
||||||
|
comparabilityStatus: "uncertain",
|
||||||
|
comparabilityReason:
|
||||||
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
||||||
|
comparabilityEvidence: [],
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
relationshipReason:
|
||||||
|
"Relationship classification is deferred until comparability is established.",
|
||||||
|
relationshipAssessed: false,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
reasoningStages: [
|
||||||
|
{
|
||||||
|
stage: "comparability",
|
||||||
|
status: "uncertain",
|
||||||
|
outcome:
|
||||||
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "relationship",
|
||||||
|
status: "insufficient_information",
|
||||||
|
outcome: "not assessed until comparability is established",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeComparabilityProposal() {
|
||||||
|
return {
|
||||||
|
addedNodes: [],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: "n-comparability-unknown",
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue:
|
||||||
|
"Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||||
|
reason: "The answer confirms comparability.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: ["n-comparability-unknown"],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("lib/graph/orchestrator startCase", () => {
|
describe("lib/graph/orchestrator startCase", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -178,6 +262,96 @@ describe("lib/graph/orchestrator startCase", () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.situationGraph.activeUnknownNodeId).toBeTruthy();
|
expect(result.situationGraph.activeUnknownNodeId).toBeTruthy();
|
||||||
|
expect(result.diagnostics.unknownSelectionExplanation?.selectedNodeId).toBe(
|
||||||
|
result.situationGraph.activeUnknownNodeId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an ambiguous tie result instead of choosing by label order", async () => {
|
||||||
|
mockAnalyseScenario.mockResolvedValue(
|
||||||
|
makeAnalysisResult({
|
||||||
|
reconstruction: {
|
||||||
|
summary: "Revenue up while cash falls",
|
||||||
|
actors: [],
|
||||||
|
systemsOrObjects: [],
|
||||||
|
expectedStates: [],
|
||||||
|
observedStates: [
|
||||||
|
{
|
||||||
|
id: "obs-1",
|
||||||
|
label: "Revenue increased by 18%.",
|
||||||
|
description: "Revenue increased by 18%.",
|
||||||
|
confidence: "high",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "obs-2",
|
||||||
|
label: "Cash in the bank decreased over the same period.",
|
||||||
|
description: "Cash in the bank decreased over the same period.",
|
||||||
|
confidence: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
differences: [],
|
||||||
|
knownTransitions: [],
|
||||||
|
unexplainedTransitions: [],
|
||||||
|
contradictions: [
|
||||||
|
{
|
||||||
|
id: "c-1",
|
||||||
|
label:
|
||||||
|
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
||||||
|
description:
|
||||||
|
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
||||||
|
confidence: "medium",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
importantUnknowns: [
|
||||||
|
{
|
||||||
|
id: "unk-1",
|
||||||
|
label:
|
||||||
|
"Whether revenue recognition timing differs from cash collection timing.",
|
||||||
|
description:
|
||||||
|
"Whether revenue recognition timing differs from cash collection timing.",
|
||||||
|
confidence: "high",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unk-2",
|
||||||
|
label:
|
||||||
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
||||||
|
description:
|
||||||
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
||||||
|
confidence: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
plausibleInterpretations: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
|
||||||
|
const result = await startCase({
|
||||||
|
scenario:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.situationGraph.activeUnknownNodeId).toBeNull();
|
||||||
|
expect(result.selectedQuestion).toMatchObject({
|
||||||
|
id: "q_tie_resolution",
|
||||||
|
selectionStatus: "ambiguous",
|
||||||
|
question:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
tiedCandidateIds: expect.arrayContaining([expect.any(String)]),
|
||||||
|
comparabilityStatus: "uncertain",
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
relationshipAssessed: false,
|
||||||
|
contradictionReasoningAllowed: false,
|
||||||
|
});
|
||||||
|
expect(result.diagnostics.unknownSelectionExplanation).toMatchObject({
|
||||||
|
status: "ambiguous",
|
||||||
|
tieType: "complete_unresolved_tie",
|
||||||
|
selectedNodeId: null,
|
||||||
|
alphabeticalUsedAsReasoning: false,
|
||||||
|
tieResolutionQuestion:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns structured failure when graph reference validation fails", async () => {
|
it("returns structured failure when graph reference validation fails", async () => {
|
||||||
@@ -262,6 +436,69 @@ describe("lib/graph/orchestrator startCase", () => {
|
|||||||
expect(result.diagnostics.compatibilityChanges).toHaveLength(1);
|
expect(result.diagnostics.compatibilityChanges).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves selected question bytes while adding selection explanation diagnostics", async () => {
|
||||||
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
const provider = {
|
||||||
|
generateReconstruction: vi.fn().mockResolvedValue(
|
||||||
|
makeProposal({
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-build-decision",
|
||||||
|
label: "Build Confidence Engine decision",
|
||||||
|
description: "Decision introduced by the answer.",
|
||||||
|
kind: "state",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-commercial-value",
|
||||||
|
label: "Commercial value definition",
|
||||||
|
description:
|
||||||
|
"Need a concrete definition because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
addedEdges: [
|
||||||
|
{
|
||||||
|
id: "e-build-commercial-value",
|
||||||
|
fromNodeId: "n-build-decision",
|
||||||
|
toNodeId: "n-commercial-value",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description:
|
||||||
|
"The decision depends on commercial value definition.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-commercial-value",
|
||||||
|
question:
|
||||||
|
"How should commercial value be defined for this decision?",
|
||||||
|
reason: "Consequential unresolved uncertainty remains.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await updateCase(makeUpdateRequest(), {
|
||||||
|
provider,
|
||||||
|
config: MOCK_CONFIG,
|
||||||
|
applyProposal: true,
|
||||||
|
});
|
||||||
|
const second = await updateCase(makeUpdateRequest(), {
|
||||||
|
provider,
|
||||||
|
config: MOCK_CONFIG,
|
||||||
|
applyProposal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.selectedQuestion.question).toBe(
|
||||||
|
second.selectedQuestion.question,
|
||||||
|
);
|
||||||
|
expect(first.selectedQuestion.reason).toBe(second.selectedQuestion.reason);
|
||||||
|
expect(first.diagnostics.unknownSelectionExplanation).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("produces a validated update proposal for a valid request", async () => {
|
it("produces a validated update proposal for a valid request", async () => {
|
||||||
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
const provider = {
|
const provider = {
|
||||||
@@ -777,6 +1014,85 @@ describe("lib/graph/orchestrator startCase", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("advances reasoning after comparability is resolved by the update answer", async () => {
|
||||||
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
const provider = {
|
||||||
|
generateReconstruction: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(makeComparabilityProposal()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateCase(
|
||||||
|
{
|
||||||
|
situationGraph: makeComparabilityScenarioGraph(),
|
||||||
|
previousQuestion:
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
answer:
|
||||||
|
"Yes. Both figures cover the same accounting period and are taken from the same management accounts.",
|
||||||
|
promptVersion: "v0.4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider,
|
||||||
|
config: MOCK_CONFIG,
|
||||||
|
applyProposal: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.resolvedUnknownNodeIds).toEqual(["n-comparability-unknown"]);
|
||||||
|
expect(result.diagnostics).toMatchObject({
|
||||||
|
previousComparabilityStatus: "uncertain",
|
||||||
|
comparabilityStatus: "confirmed",
|
||||||
|
relationshipStatus: "potentially_related",
|
||||||
|
relationshipAssessed: true,
|
||||||
|
resolvedReasoningNodeIds: ["reasoning:comparability"],
|
||||||
|
emergentReasoningNodeCreated: true,
|
||||||
|
atomicityAssessment: "composite",
|
||||||
|
decompositionPerformed: true,
|
||||||
|
childUnknownCount: 5,
|
||||||
|
});
|
||||||
|
expect(result.diagnostics.emergentReasoningNodeId).toBeTruthy();
|
||||||
|
expect(result.diagnostics.childNodeIds).toHaveLength(5);
|
||||||
|
expect(result.diagnostics.atomicityReason).toBeTruthy();
|
||||||
|
expect(result.diagnostics.emergentReasoningNodeReason).toContain(
|
||||||
|
"backed by the graph",
|
||||||
|
);
|
||||||
|
expect(result.diagnostics.reasoningStagesBefore).toEqual([
|
||||||
|
{
|
||||||
|
stage: "comparability",
|
||||||
|
status: "uncertain",
|
||||||
|
outcome:
|
||||||
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "relationship",
|
||||||
|
status: "insufficient_information",
|
||||||
|
outcome: "not assessed until comparability is established",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.diagnostics.reasoningStagesAfter).toEqual([
|
||||||
|
{
|
||||||
|
stage: "comparability",
|
||||||
|
status: "confirmed",
|
||||||
|
outcome:
|
||||||
|
"Comparability was confirmed by the user answer covering the same period and source basis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "relationship",
|
||||||
|
status: "potentially_related",
|
||||||
|
outcome:
|
||||||
|
"The observations concern connected business signals but do not establish a direct contradiction or cause.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId);
|
||||||
|
expect(result.selectedQuestion?.question).toBe(
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
);
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch(
|
||||||
|
/same basis|dso|receivables|debtor days|working capital/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("startCase behaviour remains unchanged", async () => {
|
it("startCase behaviour remains unchanged", async () => {
|
||||||
mockAnalyseScenario.mockResolvedValue(makeAnalysisResult());
|
mockAnalyseScenario.mockResolvedValue(makeAnalysisResult());
|
||||||
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
const { startCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formulateQuestion } from "@/lib/graph/question-formulator.js";
|
import {
|
||||||
|
assessUnknownAtomicity,
|
||||||
|
formulateQuestion,
|
||||||
|
formulateTieResolutionQuestion,
|
||||||
|
selectInvestigationStrategy,
|
||||||
|
} from "@/lib/graph/question-formulator.js";
|
||||||
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
function makeGraphFor(node, extra = {}) {
|
function makeGraphFor(node, extra = {}) {
|
||||||
@@ -14,7 +19,64 @@ function makeGraphFor(node, extra = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("formulateQuestion", () => {
|
describe("formulateQuestion", () => {
|
||||||
it("commercial viability plus build decision produces a decision-criterion question", () => {
|
it("atomicity assessment leaves focused unknowns direct and marks broad explanation unknowns composite", () => {
|
||||||
|
const atomicUnknown = makeNode({
|
||||||
|
id: "n-atomic",
|
||||||
|
label: "Complaint rate denominator",
|
||||||
|
description:
|
||||||
|
"Need the denominator because it directly determines the complaint rate.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const compositeUnknown = makeNode({
|
||||||
|
id: "n-composite",
|
||||||
|
label:
|
||||||
|
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
const compositeGraph = makeGraphFor(compositeUnknown, {
|
||||||
|
centralStatement:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
nodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-revenue-observation",
|
||||||
|
label: "Revenue increased by 18%.",
|
||||||
|
description: "Revenue increased by 18%.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-cash-observation",
|
||||||
|
label: "Cash in the bank decreased over the same period.",
|
||||||
|
description: "Cash in the bank decreased over the same period.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
assessUnknownAtomicity({
|
||||||
|
node: atomicUnknown,
|
||||||
|
graph: makeGraphFor(atomicUnknown),
|
||||||
|
}).atomicity,
|
||||||
|
).toBe("atomic");
|
||||||
|
expect(
|
||||||
|
assessUnknownAtomicity({
|
||||||
|
node: compositeUnknown,
|
||||||
|
graph: compositeGraph,
|
||||||
|
}).atomicity,
|
||||||
|
).toBe("composite");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commercial viability plus build decision produces a decision-threshold question", () => {
|
||||||
const unknown = makeNode({
|
const unknown = makeNode({
|
||||||
id: "n-commercial",
|
id: "n-commercial",
|
||||||
label: "Uncertainty regarding the commercial value of the product",
|
label: "Uncertainty regarding the commercial value of the product",
|
||||||
@@ -42,7 +104,7 @@ describe("formulateQuestion", () => {
|
|||||||
|
|
||||||
const result = formulateQuestion({ node: unknown, graph });
|
const result = formulateQuestion({ node: unknown, graph });
|
||||||
|
|
||||||
expect(result.strategy).toBe("decision criterion");
|
expect(result.strategy).toBe("decision_threshold");
|
||||||
expect(result.question).toContain("What outcome");
|
expect(result.question).toContain("What outcome");
|
||||||
expect(result.question.toLowerCase()).toContain("justify");
|
expect(result.question.toLowerCase()).toContain("justify");
|
||||||
});
|
});
|
||||||
@@ -100,7 +162,7 @@ describe("formulateQuestion", () => {
|
|||||||
graph: makeGraphFor(unknown),
|
graph: makeGraphFor(unknown),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.strategy).toBe("evidence");
|
expect(result.strategy).toBe("evidence_gathering");
|
||||||
expect(result.question).toContain("What evidence");
|
expect(result.question).toContain("What evidence");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,33 +182,41 @@ describe("formulateQuestion", () => {
|
|||||||
graph: makeGraphFor(unknown),
|
graph: makeGraphFor(unknown),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.strategy).toBe("baseline");
|
expect(result.strategy).toBe("baseline_reconstruction");
|
||||||
expect(result.question).toContain("What was the comparable state before");
|
expect(result.question).toContain("What was the comparable state before");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unknown customer produces an actor/customer question", () => {
|
it("conflicting claim produces a contradiction-resolution question", () => {
|
||||||
const unknown = makeNode({
|
const unknown = makeNode({
|
||||||
id: "n-customer",
|
id: "n-conflict",
|
||||||
label: "Target customer",
|
label: "Conflicting churn claim",
|
||||||
description:
|
description:
|
||||||
"Need to know the customer because value depends on who receives it.",
|
"Need to resolve the inconsistency because the current figures contradict each other.",
|
||||||
kind: "unknown",
|
kind: "unknown",
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
confidence: "high",
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const contradiction = makeNode({
|
||||||
|
id: "n-contradiction",
|
||||||
|
label: "Contradicted report",
|
||||||
|
description: "Two sources disagree about churn.",
|
||||||
|
kind: "conclusion",
|
||||||
|
status: "contradicted",
|
||||||
|
confidence: "low",
|
||||||
|
childIds: [unknown.id],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = formulateQuestion({
|
const result = formulateQuestion({
|
||||||
node: unknown,
|
node: unknown,
|
||||||
graph: makeGraphFor(unknown),
|
graph: makeGraphFor(unknown, { nodes: [contradiction] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.strategy).toBe("actor/customer");
|
expect(result.strategy).toBe("contradiction_resolution");
|
||||||
expect(result.question).toContain(
|
expect(result.question).toContain("resolve the contradiction");
|
||||||
"Who experiences the problem or receives the value",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("constraint unknown produces a constraint question", () => {
|
it("constraint unknown uses evidence-gathering within the fixed strategy set", () => {
|
||||||
const unknown = makeNode({
|
const unknown = makeNode({
|
||||||
id: "n-constraint",
|
id: "n-constraint",
|
||||||
label: "Budget constraint",
|
label: "Budget constraint",
|
||||||
@@ -162,8 +232,95 @@ describe("formulateQuestion", () => {
|
|||||||
graph: makeGraphFor(unknown),
|
graph: makeGraphFor(unknown),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.strategy).toBe("constraint");
|
expect(result.strategy).toBe("evidence_gathering");
|
||||||
expect(result.question).toContain("What constraint most limits");
|
expect(result.question).toContain("What evidence");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the same unknown can produce different questions when paired with different strategies", () => {
|
||||||
|
const thresholdUnknown = makeNode({
|
||||||
|
id: "n-threshold-unknown",
|
||||||
|
label: "Value threshold",
|
||||||
|
description: "Need to resolve the value threshold.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const definitionUnknown = makeNode({
|
||||||
|
id: "n-definition-unknown",
|
||||||
|
label: "Value term",
|
||||||
|
description: "Need to resolve what value term refers to in this context.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
const decisionGraph = makeGraphFor(thresholdUnknown, {
|
||||||
|
centralStatement: "We are deciding whether to launch this product.",
|
||||||
|
nodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-decision",
|
||||||
|
label: "Launch decision",
|
||||||
|
description: "Decision depends on the value threshold.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "medium",
|
||||||
|
childIds: [thresholdUnknown.id],
|
||||||
|
value: "Deciding whether to launch the product",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const definitionGraph = makeGraphFor(definitionUnknown, {
|
||||||
|
centralStatement:
|
||||||
|
"The team uses the term value threshold inconsistently.",
|
||||||
|
nodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-definition",
|
||||||
|
label: "Definition disagreement about value threshold",
|
||||||
|
description:
|
||||||
|
"Need a definition of value threshold because the term is used inconsistently before comparing options.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "medium",
|
||||||
|
childIds: [definitionUnknown.id],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const decisionResult = formulateQuestion({
|
||||||
|
node: thresholdUnknown,
|
||||||
|
graph: decisionGraph,
|
||||||
|
});
|
||||||
|
const definitionResult = formulateQuestion({
|
||||||
|
node: definitionUnknown,
|
||||||
|
graph: definitionGraph,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decisionResult.strategy).toBe("decision_threshold");
|
||||||
|
expect(definitionResult.strategy).toBe("definition");
|
||||||
|
expect(decisionResult.question).not.toBe(definitionResult.question);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strategy selection is deterministic and explainable", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-threshold",
|
||||||
|
label: "Success threshold",
|
||||||
|
description:
|
||||||
|
"Need the success threshold because the decision depends on it.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const graph = makeGraphFor(unknown, {
|
||||||
|
centralStatement: "We need to decide whether to continue investing.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = selectInvestigationStrategy({ node: unknown, graph });
|
||||||
|
const second = selectInvestigationStrategy({ node: unknown, graph });
|
||||||
|
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
expect(first.key).toBe("decision_threshold");
|
||||||
|
expect(first.reason).toContain("threshold");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("question is singular and answerable", () => {
|
it("question is singular and answerable", () => {
|
||||||
@@ -206,4 +363,112 @@ describe("formulateQuestion", () => {
|
|||||||
"What would resolve uncertainty regarding",
|
"What would resolve uncertainty regarding",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ambiguous contradiction produces a broad distinguishing question without accounting jargon", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-cause-a",
|
||||||
|
label: "Cash outflow cause",
|
||||||
|
description: "Unclear explanation for the contradiction.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const contradiction = makeNode({
|
||||||
|
id: "n-contradiction",
|
||||||
|
label: "Divergent movement between revenue and cash",
|
||||||
|
description: "Two signals moved in opposite directions.",
|
||||||
|
kind: "relationship",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
const revenueObservation = makeNode({
|
||||||
|
id: "n-revenue-observation",
|
||||||
|
label: "Revenue increased by 18%.",
|
||||||
|
description: "Revenue increased by 18%.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const cashObservation = makeNode({
|
||||||
|
id: "n-cash-observation",
|
||||||
|
label: "Cash in the bank decreased over the same period.",
|
||||||
|
description: "Cash in the bank decreased over the same period.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const graph = makeGraphFor(unknown, {
|
||||||
|
centralStatement:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
nodes: [contradiction, revenueObservation, cashObservation],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateTieResolutionQuestion({ graph });
|
||||||
|
|
||||||
|
expect(result.question).toBe(
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
);
|
||||||
|
expect(result.comparabilityStatus).toBe("uncertain");
|
||||||
|
expect(result.question.toLowerCase()).not.toMatch(
|
||||||
|
/accounts receivable|capex|debt repayments|working capital/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("definition is selected only for genuine definition unknowns", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-definition-only",
|
||||||
|
label: "Definition of success criteria",
|
||||||
|
description: "The term is used inconsistently and needs a definition.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
expect(result.strategy).toBe("definition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unknown about possible causes does not become a definition question", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-causes",
|
||||||
|
label: "Possible causes of the divergence",
|
||||||
|
description: "Several causes may explain the divergence.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
expect(result.strategy).toBeNull();
|
||||||
|
expect(result.question).toBe(
|
||||||
|
"What would clarify possible causes of the divergence in this situation?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed punctuation is rejected", () => {
|
||||||
|
const unknown = makeNode({
|
||||||
|
id: "n-punct",
|
||||||
|
label: "Magnitude and nature of cash outflows (operating expenses).",
|
||||||
|
description:
|
||||||
|
"Magnitude and nature of cash outflows (operating expenses).",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = formulateQuestion({
|
||||||
|
node: unknown,
|
||||||
|
graph: makeGraphFor(unknown),
|
||||||
|
});
|
||||||
|
expect(result.question).not.toContain("). is true?");
|
||||||
|
expect(result.question).toBe(
|
||||||
|
"What would clarify magnitude and nature of cash outflows (operating expenses) in this situation?",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,27 +127,27 @@ describe("question priority generalisation", () => {
|
|||||||
{
|
{
|
||||||
"nodeId": "hire-success-criteria",
|
"nodeId": "hire-success-criteria",
|
||||||
"scenario": "Should we hire another engineer?",
|
"scenario": "Should we hire another engineer?",
|
||||||
"strategy": "decision criterion",
|
"strategy": "decision_threshold",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nodeId": "van-reliability-threshold",
|
"nodeId": "van-reliability-threshold",
|
||||||
"scenario": "Should we replace the delivery vans?",
|
"scenario": "Should we replace the delivery vans?",
|
||||||
"strategy": "decision criterion",
|
"strategy": "decision_threshold",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nodeId": "country-value-threshold",
|
"nodeId": "country-value-threshold",
|
||||||
"scenario": "Should we launch in another country?",
|
"scenario": "Should we launch in another country?",
|
||||||
"strategy": "actor/customer",
|
"strategy": "decision_threshold",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nodeId": "project-benefit-threshold",
|
"nodeId": "project-benefit-threshold",
|
||||||
"scenario": "Should we continue a project that is over budget?",
|
"scenario": "Should we continue a project that is over budget?",
|
||||||
"strategy": "decision criterion",
|
"strategy": "decision_threshold",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nodeId": "support-value-threshold",
|
"nodeId": "support-value-threshold",
|
||||||
"scenario": "Should we introduce a paid support tier?",
|
"scenario": "Should we introduce a paid support tier?",
|
||||||
"strategy": "actor/customer",
|
"strategy": "baseline_reconstruction",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
formulateQuestion,
|
||||||
|
formulateTieResolutionQuestion,
|
||||||
|
} from "@/lib/graph/question-formulator.js";
|
||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
import {
|
||||||
|
explainUnknownSelection,
|
||||||
|
selectActiveUnknownCandidate,
|
||||||
|
} from "@/lib/graph/utils.js";
|
||||||
|
|
||||||
|
function buildLiveShapedGraph() {
|
||||||
|
const summary = makeNode({
|
||||||
|
id: "nnvog0y",
|
||||||
|
label:
|
||||||
|
"Revenue grew by 18% while corporate cash reserves declined over an identical time frame.",
|
||||||
|
description: "Summary of the situation from the scenario text",
|
||||||
|
kind: "state",
|
||||||
|
status: "provisional",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
const revenueObservation = makeNode({
|
||||||
|
id: "nri36w9",
|
||||||
|
label: "Revenue increased by 18%.",
|
||||||
|
description: "Revenue increased by 18%.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
evidenceIds: ["obs_rev"],
|
||||||
|
});
|
||||||
|
const cashObservation = makeNode({
|
||||||
|
id: "nnfc48j",
|
||||||
|
label: "Cash in the bank decreased over the same period.",
|
||||||
|
description: "Cash in the bank decreased over the same period.",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
evidenceIds: ["obs_cash"],
|
||||||
|
});
|
||||||
|
const revenueMetric = makeNode({
|
||||||
|
id: "nhsd6d5",
|
||||||
|
label: "Revenue metric (typically accrual-based income statement figure)",
|
||||||
|
description:
|
||||||
|
"Revenue metric (typically accrual-based income statement figure)",
|
||||||
|
kind: "metric",
|
||||||
|
status: "known",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const cashMetric = makeNode({
|
||||||
|
id: "neh5m6m",
|
||||||
|
label:
|
||||||
|
"Cash balance (liquidity measure on the balance sheet or cash flow statement)",
|
||||||
|
description:
|
||||||
|
"Cash balance (liquidity measure on the balance sheet or cash flow statement)",
|
||||||
|
kind: "metric",
|
||||||
|
status: "known",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const directionalRelationship = makeNode({
|
||||||
|
id: "nwo6070",
|
||||||
|
label:
|
||||||
|
"Divergent directional movement between top-line revenue growth and net cash position contraction.",
|
||||||
|
description:
|
||||||
|
"Divergent directional movement between top-line revenue growth and net cash position contraction.",
|
||||||
|
kind: "relationship",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const contradictionRelationship = makeNode({
|
||||||
|
id: "nuiab02",
|
||||||
|
label:
|
||||||
|
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
||||||
|
description:
|
||||||
|
"Apparent contradiction between profitability/revenue expansion and liquidity reduction.",
|
||||||
|
kind: "relationship",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
const cashTiming = makeNode({
|
||||||
|
id: "niewza",
|
||||||
|
label:
|
||||||
|
"Whether revenue recognition timing differs from cash collection timing.",
|
||||||
|
description:
|
||||||
|
"Whether revenue recognition timing differs from cash collection timing.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const cashOutflows = makeNode({
|
||||||
|
id: "nqdzobz",
|
||||||
|
label:
|
||||||
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
||||||
|
description:
|
||||||
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-revenue-summary",
|
||||||
|
fromNodeId: revenueObservation.id,
|
||||||
|
toNodeId: summary.id,
|
||||||
|
relationship: "supports",
|
||||||
|
description: "Revenue increase supports the scenario summary.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-cash-summary",
|
||||||
|
fromNodeId: cashObservation.id,
|
||||||
|
toNodeId: summary.id,
|
||||||
|
relationship: "supports",
|
||||||
|
description: "Cash decline supports the scenario summary.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-unk-niewza",
|
||||||
|
fromNodeId: cashTiming.id,
|
||||||
|
toNodeId: summary.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description:
|
||||||
|
"Whether revenue recognition timing differs from cash collection timing. is an unresolved factor for this situation",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: "e-unk-nqdzobz",
|
||||||
|
fromNodeId: cashOutflows.id,
|
||||||
|
toNodeId: summary.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description:
|
||||||
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts). is an unresolved factor for this situation",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return makeGraph({
|
||||||
|
centralStatement:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
nodes: [
|
||||||
|
summary,
|
||||||
|
revenueObservation,
|
||||||
|
cashObservation,
|
||||||
|
revenueMetric,
|
||||||
|
cashMetric,
|
||||||
|
directionalRelationship,
|
||||||
|
contradictionRelationship,
|
||||||
|
cashTiming,
|
||||||
|
cashOutflows,
|
||||||
|
],
|
||||||
|
edges,
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Diagnostic selection influence fixture",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderCandidates(explanation) {
|
||||||
|
return explanation.candidates.map((candidate) => ({
|
||||||
|
nodeId: candidate.nodeId,
|
||||||
|
label: candidate.label,
|
||||||
|
score: candidate.score,
|
||||||
|
downstreamCount: candidate.downstreamCount,
|
||||||
|
unresolvedParentUnknownCount: candidate.unresolvedParentUnknownCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDependencyLinks(graph) {
|
||||||
|
const nodes = graph.nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
parentId: null,
|
||||||
|
childIds: [],
|
||||||
|
}));
|
||||||
|
const edges = (graph.edges || []).filter(
|
||||||
|
(edge) => edge.relationship !== "depends_on",
|
||||||
|
);
|
||||||
|
return makeGraph({ ...graph, nodes, edges, activeUnknownNodeId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
function neutraliseUnknownWording(graph) {
|
||||||
|
let counter = 0;
|
||||||
|
const nodes = graph.nodes.map((node) => {
|
||||||
|
if (node.kind !== "unknown") return { ...node };
|
||||||
|
counter += 1;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
label: `Unknown ${String.fromCharCode(64 + counter)}`,
|
||||||
|
description: `Unknown factor ${counter} relevant to the scenario.`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return makeGraph({ ...graph, nodes, activeUnknownNodeId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("selection influence diagnostic", () => {
|
||||||
|
it("records ambiguous ordering changes for live-shaped, structure-only, and wording-neutralised fixtures", () => {
|
||||||
|
const liveGraph = buildLiveShapedGraph();
|
||||||
|
const liveExplanation = explainUnknownSelection(liveGraph, []);
|
||||||
|
const liveSelection = selectActiveUnknownCandidate(liveGraph, []);
|
||||||
|
const tieQuestion = formulateTieResolutionQuestion({ graph: liveGraph });
|
||||||
|
|
||||||
|
const noLinksExplanation = explainUnknownSelection(
|
||||||
|
removeDependencyLinks(liveGraph),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const noLinksSelection = selectActiveUnknownCandidate(
|
||||||
|
removeDependencyLinks(liveGraph),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const neutralWordingExplanation = explainUnknownSelection(
|
||||||
|
neutraliseUnknownWording(liveGraph),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const neutralSelection = selectActiveUnknownCandidate(
|
||||||
|
neutraliseUnknownWording(liveGraph),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallbackQuestion = formulateQuestion({
|
||||||
|
node: liveGraph.nodes.find((node) => node.id === "nqdzobz"),
|
||||||
|
graph: liveGraph,
|
||||||
|
});
|
||||||
|
|
||||||
|
const diagnosticRecord = {
|
||||||
|
liveStatus: liveExplanation.status,
|
||||||
|
liveShapedCandidateOrdering: orderCandidates(liveExplanation),
|
||||||
|
liveTiedCandidateIds: liveExplanation.tiedCandidateIds,
|
||||||
|
noLinksCandidateOrdering: orderCandidates(noLinksExplanation),
|
||||||
|
noLinksStatus: noLinksExplanation.status,
|
||||||
|
neutralWordingCandidateOrdering: orderCandidates(
|
||||||
|
neutralWordingExplanation,
|
||||||
|
),
|
||||||
|
neutralStatus: neutralWordingExplanation.status,
|
||||||
|
selectedExplanationContributions: liveExplanation.selected?.contributions,
|
||||||
|
tieQuestion: tieQuestion.question,
|
||||||
|
liveSelection,
|
||||||
|
noLinksSelection,
|
||||||
|
neutralSelection,
|
||||||
|
fallbackQuestion,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(diagnosticRecord.liveStatus).toBe("ambiguous");
|
||||||
|
expect(diagnosticRecord.liveTiedCandidateIds).toEqual([
|
||||||
|
"nqdzobz",
|
||||||
|
"niewza",
|
||||||
|
]);
|
||||||
|
expect(diagnosticRecord.liveShapedCandidateOrdering).toEqual([
|
||||||
|
{
|
||||||
|
nodeId: "nqdzobz",
|
||||||
|
label:
|
||||||
|
"Magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts).",
|
||||||
|
score: 0,
|
||||||
|
downstreamCount: 0,
|
||||||
|
unresolvedParentUnknownCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeId: "niewza",
|
||||||
|
label:
|
||||||
|
"Whether revenue recognition timing differs from cash collection timing.",
|
||||||
|
score: 0,
|
||||||
|
downstreamCount: 0,
|
||||||
|
unresolvedParentUnknownCount: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(diagnosticRecord.liveSelection).toMatchObject({
|
||||||
|
selectedNode: null,
|
||||||
|
status: "ambiguous",
|
||||||
|
tieType: "complete_unresolved_tie",
|
||||||
|
tiedCandidateIds: ["nqdzobz", "niewza"],
|
||||||
|
});
|
||||||
|
expect(diagnosticRecord.noLinksCandidateOrdering).toEqual(
|
||||||
|
diagnosticRecord.liveShapedCandidateOrdering,
|
||||||
|
);
|
||||||
|
expect(diagnosticRecord.noLinksStatus).toBe("ambiguous");
|
||||||
|
expect(diagnosticRecord.noLinksSelection.status).toBe("ambiguous");
|
||||||
|
expect(diagnosticRecord.neutralWordingCandidateOrdering).toEqual([
|
||||||
|
{
|
||||||
|
nodeId: "niewza",
|
||||||
|
label: "Unknown A",
|
||||||
|
score: 0,
|
||||||
|
downstreamCount: 0,
|
||||||
|
unresolvedParentUnknownCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeId: "nqdzobz",
|
||||||
|
label: "Unknown B",
|
||||||
|
score: 0,
|
||||||
|
downstreamCount: 0,
|
||||||
|
unresolvedParentUnknownCount: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(diagnosticRecord.neutralStatus).toBe("ambiguous");
|
||||||
|
expect(diagnosticRecord.neutralSelection.status).toBe("ambiguous");
|
||||||
|
expect(diagnosticRecord.selectedExplanationContributions).toBeUndefined();
|
||||||
|
expect(diagnosticRecord.tieQuestion).toBe(
|
||||||
|
"Were these figures measured on the same basis and at the same scale?",
|
||||||
|
);
|
||||||
|
expect(diagnosticRecord.tieQuestion.toLowerCase()).not.toMatch(
|
||||||
|
/accounts receivable|capex|debt repayments|working capital/,
|
||||||
|
);
|
||||||
|
expect(diagnosticRecord.fallbackQuestion.strategy).toBeNull();
|
||||||
|
expect(diagnosticRecord.fallbackQuestion.question).toBe(
|
||||||
|
"What would clarify magnitude and nature of cash outflows (operating expenses, debt repayments, capex, or working capital shifts) in this situation?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
|
function makePropagationFixture({
|
||||||
|
key,
|
||||||
|
centralStatement,
|
||||||
|
firstObservationLabel,
|
||||||
|
secondObservationLabel,
|
||||||
|
}) {
|
||||||
|
const parent = makeNode({
|
||||||
|
id: `${key}-parent`,
|
||||||
|
label: `Explanation for why ${centralStatement}`,
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
const measurementChild = makeNode({
|
||||||
|
id: `${key}-child-measurement`,
|
||||||
|
label: "How the two observations were measured",
|
||||||
|
description: `Need evidence about the measure used for each observation, because that could help explain ${centralStatement}.`,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
const timingChild = makeNode({
|
||||||
|
id: `${key}-child-timing`,
|
||||||
|
label: "Whether the two observations reflect different timing",
|
||||||
|
description: `Need to know whether the two observations reflect different timing, because that could help explain ${centralStatement}.`,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
const cashMovementChild = makeNode({
|
||||||
|
id: `${key}-child-cash-movement`,
|
||||||
|
label: `Possible change mainly affecting ${secondObservationLabel}`,
|
||||||
|
description: `Need to know whether a possible change mainly affected ${secondObservationLabel}, because that could help explain ${centralStatement}.`,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
const oneOffChild = makeNode({
|
||||||
|
id: `${key}-child-one-off`,
|
||||||
|
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 ${centralStatement}.`,
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
parentId: parent.id,
|
||||||
|
});
|
||||||
|
const ancestor = makeNode({
|
||||||
|
id: `${key}-ancestor`,
|
||||||
|
label: `Reasoning for ${centralStatement}`,
|
||||||
|
description:
|
||||||
|
"Higher-level reasoning node depending on the parent explanation.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
childIds: [parent.id],
|
||||||
|
});
|
||||||
|
const unrelated = makeNode({
|
||||||
|
id: `${key}-unrelated`,
|
||||||
|
label: "Unrelated branch",
|
||||||
|
description: "Should remain unchanged.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "low",
|
||||||
|
});
|
||||||
|
const firstObservation = makeNode({
|
||||||
|
id: `${key}-obs-1`,
|
||||||
|
label: firstObservationLabel,
|
||||||
|
description: firstObservationLabel,
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const secondObservation = makeNode({
|
||||||
|
id: `${key}-obs-2`,
|
||||||
|
label: secondObservationLabel,
|
||||||
|
description: secondObservationLabel,
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement,
|
||||||
|
nodes: [
|
||||||
|
ancestor,
|
||||||
|
parent,
|
||||||
|
measurementChild,
|
||||||
|
timingChild,
|
||||||
|
cashMovementChild,
|
||||||
|
oneOffChild,
|
||||||
|
unrelated,
|
||||||
|
firstObservation,
|
||||||
|
secondObservation,
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
makeEdge({
|
||||||
|
id: `${key}-e-parent-ancestor`,
|
||||||
|
fromNodeId: parent.id,
|
||||||
|
toNodeId: ancestor.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: "Ancestor depends on the parent explanation.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: `${key}-e-child-measurement-parent`,
|
||||||
|
fromNodeId: measurementChild.id,
|
||||||
|
toNodeId: parent.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: "Measurement child depends into the parent explanation.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: `${key}-e-child-timing-parent`,
|
||||||
|
fromNodeId: timingChild.id,
|
||||||
|
toNodeId: parent.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: "Timing child depends into the parent explanation.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: `${key}-e-child-cash-parent`,
|
||||||
|
fromNodeId: cashMovementChild.id,
|
||||||
|
toNodeId: parent.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: "Cash-movement child depends into the parent explanation.",
|
||||||
|
}),
|
||||||
|
makeEdge({
|
||||||
|
id: `${key}-e-child-one-off-parent`,
|
||||||
|
fromNodeId: oneOffChild.id,
|
||||||
|
toNodeId: parent.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
description: "One-off child depends into the parent explanation.",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
activeUnknownNodeId: measurementChild.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: `Propagation fixture for ${key}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
graph,
|
||||||
|
ids: {
|
||||||
|
ancestor: ancestor.id,
|
||||||
|
parent: parent.id,
|
||||||
|
measurementChild: measurementChild.id,
|
||||||
|
timingChild: timingChild.id,
|
||||||
|
cashMovementChild: cashMovementChild.id,
|
||||||
|
oneOffChild: oneOffChild.id,
|
||||||
|
unrelated: unrelated.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
key: "revenue-cash",
|
||||||
|
centralStatement: "revenue increased while cash fell",
|
||||||
|
firstObservationLabel: "Revenue increased by 18%.",
|
||||||
|
secondObservationLabel: "Cash in the bank fell over the same period.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "satisfaction-complaints",
|
||||||
|
centralStatement:
|
||||||
|
"customer satisfaction increased while complaints increased",
|
||||||
|
firstObservationLabel: "Customer satisfaction increased.",
|
||||||
|
secondObservationLabel: "Complaints increased.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "traffic-sales",
|
||||||
|
centralStatement: "traffic increased while sales stayed flat",
|
||||||
|
firstObservationLabel: "Website traffic increased.",
|
||||||
|
secondObservationLabel: "Sales stayed flat.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delivery-cancellations",
|
||||||
|
centralStatement: "delivery time fell while cancellations increased",
|
||||||
|
firstObservationLabel: "Average delivery time decreased.",
|
||||||
|
secondObservationLabel: "Cancellations increased.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "production-defects",
|
||||||
|
centralStatement: "production increased while defects increased",
|
||||||
|
firstObservationLabel: "Production increased.",
|
||||||
|
secondObservationLabel: "Defects increased.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("upward propagation", () => {
|
||||||
|
it.each(scenarios)(
|
||||||
|
"propagates resolved measurement child upward for $key",
|
||||||
|
({
|
||||||
|
key,
|
||||||
|
centralStatement,
|
||||||
|
firstObservationLabel,
|
||||||
|
secondObservationLabel,
|
||||||
|
}) => {
|
||||||
|
const { graph, ids } = makePropagationFixture({
|
||||||
|
key,
|
||||||
|
centralStatement,
|
||||||
|
firstObservationLabel,
|
||||||
|
secondObservationLabel,
|
||||||
|
});
|
||||||
|
const unrelatedBefore = JSON.stringify(
|
||||||
|
graph.nodes.find((node) => node.id === ids.unrelated),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: `${key}-anchor`,
|
||||||
|
label: "Update anchor",
|
||||||
|
description:
|
||||||
|
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "low",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.measurementChild,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue:
|
||||||
|
"The figures were measured over the same accounting period using the same management accounts.",
|
||||||
|
reason: "The answer resolves the measurement child.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [ids.measurementChild],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
|
},
|
||||||
|
previousQuestion:
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
answer:
|
||||||
|
"The figures were measured over the same accounting period using the same management accounts.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.resolvedUnknownNodeIds).toContain(ids.measurementChild);
|
||||||
|
expect(result.propagationPerformed).toBe(true);
|
||||||
|
expect(result.resolvedChildNodeId).toBe(ids.measurementChild);
|
||||||
|
expect(result.parentNodeId).toBe(ids.parent);
|
||||||
|
expect(result.parentStatusBefore).toBe("unknown");
|
||||||
|
expect(result.parentStatusAfter).toBe("provisional");
|
||||||
|
expect(result.parentConfidenceBefore).toBe("medium");
|
||||||
|
expect(result.parentConfidenceAfter).toBe("medium");
|
||||||
|
expect(result.evidenceConfidenceBefore).toBe("medium");
|
||||||
|
expect(result.evidenceConfidenceAfter).toBe("medium");
|
||||||
|
expect(result.completenessBefore).toBe("empty");
|
||||||
|
expect(result.completenessAfter).toBe("partial");
|
||||||
|
expect(result.conclusionConfidenceBefore).toBe("low");
|
||||||
|
expect(result.conclusionConfidenceAfter).toBe("medium");
|
||||||
|
expect(result.confidenceCapReason).toBe(
|
||||||
|
"unresolved_direct_children_cap_conclusion",
|
||||||
|
);
|
||||||
|
expect(result.parentResolved).toBe(false);
|
||||||
|
expect(result.affectedAncestorIds).toContain(ids.parent);
|
||||||
|
expect(result.affectedAncestorIds).toContain(ids.ancestor);
|
||||||
|
expect(result.nextSelectedSibling).toBe(result.newActiveUnknownNodeId);
|
||||||
|
expect(result.nextSelectedSibling).toBe(result.selectedQuestion?.nodeId);
|
||||||
|
expect(result.nextSelectedSibling).not.toBe(ids.measurementChild);
|
||||||
|
expect([
|
||||||
|
ids.timingChild,
|
||||||
|
ids.cashMovementChild,
|
||||||
|
ids.oneOffChild,
|
||||||
|
]).toContain(result.nextSelectedSibling);
|
||||||
|
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
|
||||||
|
"measured",
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentNode = result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === ids.parent,
|
||||||
|
);
|
||||||
|
expect(parentNode).toMatchObject({
|
||||||
|
status: "provisional",
|
||||||
|
confidence: "medium",
|
||||||
|
confidenceAssessment: {
|
||||||
|
evidenceConfidence: "medium",
|
||||||
|
completenessStatus: "partial",
|
||||||
|
conclusionConfidence: "medium",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ancestorNode = result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === ids.ancestor,
|
||||||
|
);
|
||||||
|
expect(ancestorNode).toMatchObject({
|
||||||
|
status: "provisional",
|
||||||
|
confidence: "low",
|
||||||
|
confidenceAssessment: {
|
||||||
|
evidenceConfidence: "low",
|
||||||
|
completenessStatus: "empty",
|
||||||
|
conclusionConfidence: "low",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedChild = result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === ids.measurementChild,
|
||||||
|
);
|
||||||
|
expect(resolvedChild.status).toBe("resolved");
|
||||||
|
expect(resolvedChild.evidenceIds).toContain(
|
||||||
|
`answer:${ids.measurementChild}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.nodes.filter(
|
||||||
|
(node) => node.id === ids.measurementChild,
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
JSON.stringify(
|
||||||
|
result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === ids.unrelated,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(unrelatedBefore);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("resolves the parent only after all direct children are resolved", () => {
|
||||||
|
const { graph, ids } = makePropagationFixture({
|
||||||
|
key: "completion-rule",
|
||||||
|
centralStatement: "revenue increased while cash fell",
|
||||||
|
firstObservationLabel: "Revenue increased by 18%.",
|
||||||
|
secondObservationLabel: "Cash in the bank fell over the same period.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "completion-rule-anchor",
|
||||||
|
label: "Update anchor",
|
||||||
|
description:
|
||||||
|
"Anchor state introduced by the answer because the update must contain a meaningful change.",
|
||||||
|
kind: "state",
|
||||||
|
status: "known",
|
||||||
|
confidence: "low",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.measurementChild,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "same management accounts",
|
||||||
|
reason: "resolved measurement child",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeId: ids.timingChild,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "timing aligned",
|
||||||
|
reason: "resolved timing child",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeId: ids.cashMovementChild,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "cash left through operations",
|
||||||
|
reason: "resolved movement child",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeId: ids.oneOffChild,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: "no exceptional movement",
|
||||||
|
reason: "resolved one-off child",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [
|
||||||
|
ids.measurementChild,
|
||||||
|
ids.timingChild,
|
||||||
|
ids.cashMovementChild,
|
||||||
|
ids.oneOffChild,
|
||||||
|
],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
selectedQuestion: null,
|
||||||
|
},
|
||||||
|
previousQuestion:
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
answer: "All direct child questions are now answered.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.parentResolved).toBe(true);
|
||||||
|
expect(result.resolvedUnknownNodeIds).toContain(ids.parent);
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.nodes.find((node) => node.id === ids.parent),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "resolved",
|
||||||
|
confidence: "high",
|
||||||
|
confidenceAssessment: {
|
||||||
|
evidenceConfidence: "high",
|
||||||
|
completenessStatus: "complete",
|
||||||
|
conclusionConfidence: "high",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -456,6 +456,7 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
|
|
||||||
const result = selectActiveUnknownCandidate(graph, []);
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
expect(result.nodeId).toBe("unknown-a"); // Has more dependents (score 2 vs 0)
|
expect(result.nodeId).toBe("unknown-a"); // Has more dependents (score 2 vs 0)
|
||||||
|
expect(result.status).toBe("selected");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns one candidate (not array)", () => {
|
it("returns one candidate (not array)", () => {
|
||||||
@@ -619,6 +620,72 @@ describe("selectActiveUnknownCandidate", () => {
|
|||||||
const childScore = scoreUnknownCandidate(graph, childUnknown, []);
|
const childScore = scoreUnknownCandidate(graph, childUnknown, []);
|
||||||
expect(parentScore.score).toBeGreaterThan(childScore.score);
|
expect(parentScore.score).toBeGreaterThan(childScore.score);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns ambiguous for a complete unresolved tie instead of label-based winner", () => {
|
||||||
|
const unknownA = makeNode({
|
||||||
|
id: "tie-a",
|
||||||
|
label: "Magnitude and nature of cash outflows",
|
||||||
|
description: "Magnitude and nature of cash outflows.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const unknownB = makeNode({
|
||||||
|
id: "tie-b",
|
||||||
|
label:
|
||||||
|
"Whether revenue recognition timing differs from cash collection timing",
|
||||||
|
description:
|
||||||
|
"Whether revenue recognition timing differs from cash collection timing.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
});
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement:
|
||||||
|
"Revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
nodes: [unknownA, unknownB],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Tie case",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
selectedNode: null,
|
||||||
|
status: "ambiguous",
|
||||||
|
tieType: "complete_unresolved_tie",
|
||||||
|
tiedCandidateIds: ["tie-a", "tie-b"],
|
||||||
|
});
|
||||||
|
expect(result.nodeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alphabetical renaming does not resolve a complete tie", () => {
|
||||||
|
const unknownA = makeNode({
|
||||||
|
id: "tie-a",
|
||||||
|
label: "Unknown B",
|
||||||
|
description: "Unknown factor one.",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
|
const unknownB = makeNode({
|
||||||
|
id: "tie-b",
|
||||||
|
label: "Unknown A",
|
||||||
|
description: "Unknown factor two.",
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Two conflicting signals remain unresolved.",
|
||||||
|
nodes: [unknownA, unknownB],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: null,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Tie case",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = selectActiveUnknownCandidate(graph, []);
|
||||||
|
expect(result.status).toBe("ambiguous");
|
||||||
|
expect(result.tiedCandidateIds.sort()).toEqual(["tie-a", "tie-b"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyGraphUpdate", () => {
|
describe("applyGraphUpdate", () => {
|
||||||
|
|||||||
+156
-21
@@ -80,7 +80,7 @@ function makeUpdateSuccess(overrides = {}) {
|
|||||||
updatedSituationGraph: {
|
updatedSituationGraph: {
|
||||||
centralStatement: "Complaints increased while production increased.",
|
centralStatement: "Complaints increased while production increased.",
|
||||||
currentSummary: "Updated summary",
|
currentSummary: "Updated summary",
|
||||||
activeUnknownNodeId: "n-next-unknown",
|
activeUnknownNodeId: "n-child-1",
|
||||||
resolvedNodeIds: ["n-unknown"],
|
resolvedNodeIds: ["n-unknown"],
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
@@ -115,32 +115,83 @@ function makeUpdateSuccess(overrides = {}) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "n-next-unknown",
|
id: "n-next-unknown",
|
||||||
label: "Commercial value definition",
|
label:
|
||||||
description: "Need a definition because the decision depends on it.",
|
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
kind: "unknown",
|
kind: "unknown",
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
confidence: "high",
|
confidence: "medium",
|
||||||
value: null,
|
value: null,
|
||||||
unit: null,
|
unit: null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "n-child-1",
|
||||||
|
label: "How the two observations were measured",
|
||||||
|
description:
|
||||||
|
"Need evidence about the measure used for each observation, because that could help explain revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
value: null,
|
||||||
|
unit: null,
|
||||||
|
parentId: "n-next-unknown",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: "e-rel-next",
|
||||||
|
fromNodeId: "n-conclusion",
|
||||||
|
toNodeId: "n-next-unknown",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description:
|
||||||
|
"This unresolved explanation arises from the now-assessed relationship between the observations.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e-child-next",
|
||||||
|
fromNodeId: "n-child-1",
|
||||||
|
toNodeId: "n-next-unknown",
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: "medium",
|
||||||
|
description:
|
||||||
|
"This child unknown must be investigated before the broader parent explanation can be resolved.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
edges: [],
|
|
||||||
},
|
},
|
||||||
proposal: {
|
proposal: {
|
||||||
addedNodes: [
|
addedNodes: [
|
||||||
{
|
{
|
||||||
id: "n-next-unknown",
|
id: "n-next-unknown",
|
||||||
label: "Commercial value definition",
|
label:
|
||||||
description: "Need a definition because the decision depends on it.",
|
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||||
|
description:
|
||||||
|
"Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.",
|
||||||
kind: "unknown",
|
kind: "unknown",
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
confidence: "high",
|
confidence: "medium",
|
||||||
|
value: null,
|
||||||
|
unit: null,
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: ["n-conclusion"],
|
||||||
|
affects: [],
|
||||||
|
parentId: "n-conclusion",
|
||||||
|
childIds: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n-child-1",
|
||||||
|
label: "How the two observations were measured",
|
||||||
|
description:
|
||||||
|
"Need evidence about the measure used for each observation, because that could help explain revenue increased by 18%, but cash in the bank fell over the same period.",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
value: null,
|
value: null,
|
||||||
unit: null,
|
unit: null,
|
||||||
evidenceIds: [],
|
evidenceIds: [],
|
||||||
dependsOn: [],
|
dependsOn: [],
|
||||||
affects: [],
|
affects: [],
|
||||||
parentId: null,
|
parentId: "n-next-unknown",
|
||||||
childIds: [],
|
childIds: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -152,20 +203,68 @@ function makeUpdateSuccess(overrides = {}) {
|
|||||||
resolvedUnknownNodeIds: ["n-unknown"],
|
resolvedUnknownNodeIds: ["n-unknown"],
|
||||||
affectedNodeIds: ["n-conclusion"],
|
affectedNodeIds: ["n-conclusion"],
|
||||||
selectedQuestion: {
|
selectedQuestion: {
|
||||||
nodeId: "n-next-unknown",
|
nodeId: "n-child-1",
|
||||||
question: "How should commercial value be defined for this decision?",
|
question:
|
||||||
reason: "A narrower consequential uncertainty remains.",
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
reason:
|
||||||
|
"Formulated from graph context using the evidence_gathering investigation strategy.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
selectedQuestion: {
|
selectedQuestion: {
|
||||||
nodeId: "n-next-unknown",
|
nodeId: "n-child-1",
|
||||||
question: "How should commercial value be defined for this decision?",
|
question:
|
||||||
reason: "A narrower consequential uncertainty remains.",
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
reason:
|
||||||
|
"Formulated from graph context using the evidence_gathering investigation strategy.",
|
||||||
},
|
},
|
||||||
affectedNodeIds: ["n-conclusion"],
|
affectedNodeIds: ["n-conclusion"],
|
||||||
resolvedUnknownNodeIds: ["n-unknown"],
|
resolvedUnknownNodeIds: ["n-unknown"],
|
||||||
previousActiveUnknownNodeId: "n-unknown",
|
previousActiveUnknownNodeId: "n-unknown",
|
||||||
newActiveUnknownNodeId: "n-next-unknown",
|
newActiveUnknownNodeId: "n-child-1",
|
||||||
|
emergentReasoningNodeCreated: true,
|
||||||
|
emergentReasoningNodeId: "n-next-unknown",
|
||||||
|
emergentReasoningNodeReason:
|
||||||
|
"Created a new unresolved reasoning unknown so the next justified question is backed by the graph.",
|
||||||
|
atomicityAssessment: "composite",
|
||||||
|
decompositionPerformed: true,
|
||||||
|
childUnknownCount: 1,
|
||||||
|
childNodeIds: ["n-child-1"],
|
||||||
|
atomicityReason:
|
||||||
|
"Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question.",
|
||||||
|
previousReasoningState: {
|
||||||
|
comparabilityStatus: "uncertain",
|
||||||
|
reasoningStages: [
|
||||||
|
{
|
||||||
|
stage: "comparability",
|
||||||
|
status: "uncertain",
|
||||||
|
outcome:
|
||||||
|
"Comparability between the observations is not yet established across period, scale, or measurement basis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "relationship",
|
||||||
|
status: "insufficient_information",
|
||||||
|
outcome: "not assessed until comparability is established",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reasoningState: {
|
||||||
|
comparabilityStatus: "confirmed",
|
||||||
|
relationshipStatus: "insufficient_information",
|
||||||
|
reasoningStages: [
|
||||||
|
{
|
||||||
|
stage: "comparability",
|
||||||
|
status: "confirmed",
|
||||||
|
outcome:
|
||||||
|
"Comparability was confirmed by the user answer covering the same period and source basis.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stage: "relationship",
|
||||||
|
status: "insufficient_information",
|
||||||
|
outcome:
|
||||||
|
"There is not enough structure to classify the relationship safely.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
changesApplied: {
|
changesApplied: {
|
||||||
updatedNodeCount: 2,
|
updatedNodeCount: 2,
|
||||||
resolvedUnknownCount: 1,
|
resolvedUnknownCount: 1,
|
||||||
@@ -370,7 +469,9 @@ describe("graph-backed UI rendering", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("Newly surfaced unknowns");
|
expect(html).toContain("Newly surfaced unknowns");
|
||||||
expect(html).toContain("Commercial value definition");
|
expect(html).toContain(
|
||||||
|
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("affected nodes render", () => {
|
it("affected nodes render", () => {
|
||||||
@@ -398,7 +499,7 @@ describe("graph-backed UI rendering", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
"How should commercial value be defined for this decision?",
|
"What evidence would clarify how the two observations were measured?",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,7 +536,9 @@ describe("graph-backed UI rendering", () => {
|
|||||||
expect(html).toContain("Previous active unknown");
|
expect(html).toContain("Previous active unknown");
|
||||||
expect(html).toContain("Complaint rate denominator");
|
expect(html).toContain("Complaint rate denominator");
|
||||||
expect(html).toContain("New active unknown");
|
expect(html).toContain("New active unknown");
|
||||||
expect(html).toContain("Commercial value definition");
|
expect(html).toContain(
|
||||||
|
"Explanation for why revenue increased by 18%, but cash in the bank fell over the same period",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("successful update renders prior and new state together", () => {
|
it("successful update renders prior and new state together", () => {
|
||||||
@@ -454,10 +557,40 @@ describe("graph-backed UI rendering", () => {
|
|||||||
expect(html).toContain("New active unknown");
|
expect(html).toContain("New active unknown");
|
||||||
expect(html).toContain("Next question");
|
expect(html).toContain("Next question");
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
"How should commercial value be defined for this decision?",
|
"What evidence would clarify how the two observations were measured?",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("update view shows comparability progression without raw ids in the normal view", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GraphUpdateView
|
||||||
|
updateResult={{
|
||||||
|
...makeUpdateSuccess({
|
||||||
|
selectedQuestion: {
|
||||||
|
nodeId: "n-next-unknown",
|
||||||
|
question:
|
||||||
|
"What evidence would clarify timing or measurement basis?",
|
||||||
|
reason: "A broad follow-up is now justified.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
previousSituationGraph: makeGraphResult().situationGraph,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Comparability:");
|
||||||
|
expect(html).toContain("uncertain → confirmed");
|
||||||
|
expect(html).toContain("Relationship status:");
|
||||||
|
expect(html).toContain("insufficient_information");
|
||||||
|
expect(html).toContain("Reasoning stages:");
|
||||||
|
expect(html).toContain("comparability: confirmed");
|
||||||
|
expect(html).toContain("relationship: insufficient_information");
|
||||||
|
expect(html).toContain(
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("reasoning:comparability");
|
||||||
|
});
|
||||||
|
|
||||||
it("situation graph marks newly surfaced and active unknowns", () => {
|
it("situation graph marks newly surfaced and active unknowns", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<SituationGraphView
|
<SituationGraphView
|
||||||
@@ -479,7 +612,9 @@ describe("graph-backed UI rendering", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("How should commercial value be defined for this decision?");
|
expect(html).toContain(
|
||||||
|
"What evidence would clarify how the two observations were measured?",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("raw ids remain only in collapsed proposal details", () => {
|
it("raw ids remain only in collapsed proposal details", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user