Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions scratch/scan-issue-7-damage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Issue #7 の被害調査(読み取り専用、2026-09-12)。
//
// cascadeSatisfyContainsParents が `contains` しか見ていなかったせいで、
// `requires` を未達のまま satisfied になったノードが既存データに残っていないかを
// 数える。ついでに、そのノードの前提がカスケードで巻き添えに書き換わっていないか
// も見る(前提側は「未達のはずが satisfied」という形でしか残らないので、
// 断定はできず候補として出す)。
//
// 使い方: bun run scratch/scan-issue-7-damage.ts <graph.json> [<graph.json> ...]
// 書き込みは一切しない。

import type { Graph, Node } from "../ts/server/model.ts";

function label(n: Node): string {
return `${n.name ?? "(no name)"} [${n.id}]`;
}

async function scan(file: string): Promise<void> {
const g = (await Bun.file(file).json()) as Graph;
const nodes = Object.values(g.nodes);

const bothKinds = nodes.filter((n) => n.contains.length > 0 && n.requires.length > 0);
// 被害の形: contains を持つ親が satisfied なのに、その requires に未達が残っている。
// 修正後のカスケードなら決して作れない状態。
const damaged = bothKinds.filter(
(n) => n.satisfied && !n.requires.every((id) => g.nodes[id]?.satisfied),
);

console.log(`\n=== ${file} ===`);
console.log(`ノード総数: ${nodes.length}`);
console.log(`requires と contains を両方持つノード: ${bothKinds.length}`);
for (const n of bothKinds) {
const unmet = n.requires.filter((id) => !g.nodes[id]?.satisfied);
console.log(
` - ${label(n)} satisfied=${n.satisfied} contains=${n.contains.length} requires=${n.requires.length} 未達の前提=${unmet.length}`,
);
}
console.log(`前提未達のまま達成済みになっているノード: ${damaged.length}`);
for (const n of damaged) {
console.log(` ! ${label(n)}`);
for (const id of n.requires) {
const req = g.nodes[id];
if (req && !req.satisfied) console.log(` 未達の前提: ${label(req)}`);
}
}
}

const files = Bun.argv.slice(2);
if (files.length === 0) {
console.error("usage: bun run scratch/scan-issue-7-damage.ts <graph.json> [...]");
process.exit(1);
}
for (const f of files) await scan(f);
75 changes: 72 additions & 3 deletions ts/server/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,17 +185,86 @@ test("cascadeSatisfyContainsParents satisfies a parent once every part is done",

// Completing a container should also run the same requires-cascade a manual
// toggle would (the container isn't a special case once it's satisfied).
// The parent's own prerequisite is already met here — see the Issue #7 tests
// below for what happens when it isn't.
test("cascadeSatisfyContainsParents also cascades the newly-satisfied parent's own requires", () => {
const g = newGraph();
g.nodes.build = node("build", { contains: ["part"], requires: ["syndicate-rank"] });
g.nodes.build = node("build", { contains: ["part"], requires: ["rank-2"] });
g.nodes.part = node("part", { satisfied: false });
g.nodes["syndicate-rank"] = node("syndicate-rank", { satisfied: false });
g.nodes["rank-2"] = node("rank-2", { requires: ["rank-1"], satisfied: true });
g.nodes["rank-1"] = node("rank-1", { satisfied: false });

g.nodes.part!.satisfied = true;
cascadeSatisfyContainsParents(g, "part");

expect(g.nodes.build?.satisfied).toBe(true);
expect(g.nodes["syndicate-rank"]?.satisfied).toBe(true);
expect(g.nodes["rank-1"]?.satisfied).toBe(true); // reached through rank-2's own chain
});

// Issue #7. Checking only `contains` let a parent holding both edge kinds
// flip to satisfied with its prerequisites still unmet — and
// cascadeSatisfyRequires then rewrote those unmet prerequisites too, so a
// node the graph was drawing as BLOCKED silently became SATISFIED and took
// its whole prerequisite chain with it.
test("cascadeSatisfyContainsParents does not satisfy a parent whose own requires are unmet", () => {
const g = newGraph();
g.nodes.build = node("build", { contains: ["part-a", "part-b"], requires: ["quest"] });
g.nodes["part-a"] = node("part-a", { satisfied: true });
g.nodes["part-b"] = node("part-b", { satisfied: false });
g.nodes.quest = node("quest", { satisfied: false });

g.nodes["part-b"]!.satisfied = true;
cascadeSatisfyContainsParents(g, "part-b");

expect(g.nodes.build?.satisfied).toBe(false);
expect(g.nodes.quest?.satisfied).toBe(false); // and the prerequisite was not dragged along
});

// Tightening the condition without widening the trigger is the trap: the
// parent would never be re-checked once its final prerequisite landed, and
// could never complete. A container has no work of its own, so "nothing left
// to do" is just as true when the last thing filled in was a prerequisite.
test("cascadeSatisfyContainsParents aggregates when the last thing satisfied is a prerequisite", () => {
const g = newGraph();
g.nodes.build = node("build", { contains: ["part"], requires: ["quest"] });
g.nodes.part = node("part", { satisfied: true });
g.nodes.quest = node("quest", { satisfied: false });

g.nodes.quest!.satisfied = true;
cascadeSatisfyContainsParents(g, "quest");

expect(g.nodes.build?.satisfied).toBe(true);
});

// Widening the trigger must not give plain prerequisite edges a new meaning:
// a node that merely requires another is not a container and never
// auto-completes.
test("cascadeSatisfyContainsParents leaves a requires-only dependent alone", () => {
const g = newGraph();
g.nodes.dependent = node("dependent", { requires: ["quest"] });
g.nodes.quest = node("quest", { satisfied: false });

g.nodes.quest!.satisfied = true;
cascadeSatisfyContainsParents(g, "quest");

expect(g.nodes.dependent?.satisfied).toBe(false);
});

// The cascade must answer the same question resolveState() does — if it is
// willing to mark a node satisfied, resolveState must have been willing to
// call it ACTIONABLE. Two sources of truth is how Issue #7 happened.
test("cascadeSatisfyContainsParents never satisfies a node resolveState calls BLOCKED", () => {
const g = newGraph();
g.nodes.build = node("build", { contains: ["part"], requires: ["quest"] });
g.nodes.part = node("part", { satisfied: false });
g.nodes.quest = node("quest", { satisfied: false });

g.nodes.part!.satisfied = true;
const before = resolveState(g, "build");
cascadeSatisfyContainsParents(g, "part");

expect(before).toBe("BLOCKED");
expect(g.nodes.build?.satisfied).toBe(false);
});

// Grandparent chain: completing the deepest part should bubble up two levels.
Expand Down
41 changes: 33 additions & 8 deletions ts/server/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,33 @@ export function cascadeUnsatisfyDependents(g: Graph, nodeId: string, seen: Set<s
}

/**
* When a node becomes satisfied, checks every node that lists it in
* `contains` (its container parent(s)) — if every one of that parent's
* `contains` children is now satisfied, the parent auto-becomes satisfied
* too, and the same `cascadeSatisfyRequires` a manual toggle would trigger
* runs for it. Recurses upward, since completing a parent can in turn
* complete a grandparent.
* When a node becomes satisfied, checks every container parent of it — if
* everything that parent still needs is now satisfied, the parent
* auto-becomes satisfied too, and the same `cascadeSatisfyRequires` a manual
* toggle would trigger runs for it. Recurses upward, since completing a
* parent can in turn complete a grandparent.
*
* "Everything it needs" means BOTH edge kinds: every `contains` child and
* every `requires` prerequisite. Checking only `contains` let a parent that
* held both kinds flip to satisfied with its prerequisites still unmet, and
* `cascadeSatisfyRequires` then rewrote those unmet prerequisites to
* satisfied as well — a node the graph was drawing as BLOCKED silently
* became SATISFIED, dragging its whole prerequisite chain with it. Found in
* the sister project (Sirube, ported from this same engine) against real
* data on 2026-09-02, where a goal that had gained both edge kinds marked
* itself and 7 prerequisites done. `resolveState()` has always refused to
* call such a node ACTIONABLE; the cascade simply wasn't asking the same
* question. Issue #7.
*
* The trigger is widened to match: a parent is re-checked when the node that
* just became satisfied is one of its `contains` children OR one of its
* `requires` prerequisites. A container has no work of its own — that is
* what makes it a container — so "nothing left to do" is just as true when
* the last thing filled in was on the prerequisite side. Parents with no
* `contains` at all are skipped, so plain prerequisite edges keep their
* meaning and never auto-satisfy anything. Tightening the condition without
* widening the trigger is the trap: the parent would then never be
* re-checked after its final prerequisite landed, and could never complete.
*
* One direction only (2026-08-26, のっちの判断): reverting one child later
* does NOT auto-revert the parent — a container node with no other way to
Expand All @@ -100,8 +121,12 @@ export function cascadeSatisfyContainsParents(g: Graph, nodeId: string, seen: Se

for (const [parentId, parent] of Object.entries(g.nodes)) {
if (parent.satisfied) continue;
if (!parent.contains.includes(nodeId)) continue;
if (!parent.contains.every((childId) => g.nodes[childId]?.satisfied)) continue;
if (parent.contains.length === 0) continue; // only containers aggregate
if (!parent.contains.includes(nodeId) && !parent.requires.includes(nodeId)) continue;
const ready =
parent.contains.every((childId) => g.nodes[childId]?.satisfied) &&
parent.requires.every((reqId) => g.nodes[reqId]?.satisfied);
if (!ready) continue;
parent.satisfied = true;
cascadeSatisfyRequires(g, parentId);
cascadeSatisfyContainsParents(g, parentId, seen);
Expand Down