diff --git a/scratch/scan-issue-7-damage.ts b/scratch/scan-issue-7-damage.ts new file mode 100644 index 0000000..1efb02d --- /dev/null +++ b/scratch/scan-issue-7-damage.ts @@ -0,0 +1,53 @@ +// Issue #7 の被害調査(読み取り専用、2026-09-12)。 +// +// cascadeSatisfyContainsParents が `contains` しか見ていなかったせいで、 +// `requires` を未達のまま satisfied になったノードが既存データに残っていないかを +// 数える。ついでに、そのノードの前提がカスケードで巻き添えに書き換わっていないか +// も見る(前提側は「未達のはずが satisfied」という形でしか残らないので、 +// 断定はできず候補として出す)。 +// +// 使い方: bun run scratch/scan-issue-7-damage.ts [ ...] +// 書き込みは一切しない。 + +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 { + 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 [...]"); + process.exit(1); +} +for (const f of files) await scan(f); diff --git a/ts/server/engine.test.ts b/ts/server/engine.test.ts index e0e4094..e2959bd 100644 --- a/ts/server/engine.test.ts +++ b/ts/server/engine.test.ts @@ -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. diff --git a/ts/server/engine.ts b/ts/server/engine.ts index df9c31f..ef09dff 100644 --- a/ts/server/engine.ts +++ b/ts/server/engine.ts @@ -79,12 +79,33 @@ export function cascadeUnsatisfyDependents(g: Graph, nodeId: string, seen: Set 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);