From c2bd71cf62ee3ce7a879d680f4420cea90c31c67 Mon Sep 17 00:00:00 2001 From: Sam Maassen <215926119+MSD21091969@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:04:54 +0200 Subject: [PATCH] feat(operad+kernel): WF21 causes ValidateCausalAcyclic + LINK apply hook (round-15 v314-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-15 ceremony validator-extension companion to v314-3-wf21-causes grammar_fragment promotion (Z440 :8000 log_seq 388, status=proposed). Adds: - ValidateCausalAcyclic(env, state) on Registry — BFS forward from tgt through outgoing WF21 causes-edges; rejects if env.SrcURN reachable. Handles non-WF21 LINKs (pass-through), non-LINK rewrites (pass-through), self-LINK 1-cycle, direct 2-cycles, transitive N-cycles, DAG forks (pass), and reverse-direction caused-by edges (ignored — only true causes-direction edges count). - runtime.go hooks both Apply (single-envelope path) and ApplyProgram (atomic-batch path; uses workingState so intra-batch causes-LINKs cannot close cycles even when individual envelopes pass against pre-batch state). - 8 unit tests covering all branches. Doctrine anchors: - kb/research/spec/07-time-fabric.md §7.3.5 (WF21 lift path) - kb/research/spec/05-external-substrates.md §5.1.5 + §5.3.5 (transitional citation surface → caused-by LINK lift) Pairs with v314-2-clock-type + v314-4-substrate-property + v314-6- channel-kind-video promotions; ontology.json bump v3.14.0 → v3.15.0 holds on ffs0 wolfram/r15-ontology-v3-15-bump branch pending this PR's merge + 5-kernel rebuild. All operad tests pass; full suite green. --- internal/kernel/runtime.go | 11 + internal/operad/validate.go | 68 ++++++ .../operad/validate_causal_acyclic_test.go | 213 ++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 internal/operad/validate_causal_acyclic_test.go diff --git a/internal/kernel/runtime.go b/internal/kernel/runtime.go index 675117f..f99905f 100644 --- a/internal/kernel/runtime.go +++ b/internal/kernel/runtime.go @@ -142,6 +142,11 @@ func (rt *Runtime) applyWithOptions(env graph.Envelope, opts applyOptions) (grap if err := rt.registry.ValidateStrataLink(env, rt.state); err != nil { return graph.EvalResult{}, err } + // WF21 (round-15 v314-3-wf21-causes) acyclicity check. Other LINK + // rewrite-categories pass through; only WF21 enforces DAG semantics. + if err := rt.registry.ValidateCausalAcyclic(env, rt.state); err != nil { + return graph.EvalResult{}, err + } } // Gate check (M8): fail-closed if any gate node guards the affected node and its predicate fails. @@ -250,6 +255,12 @@ func (rt *Runtime) ApplyProgram(envelopes []graph.Envelope) ([]graph.EvalResult, if err := rt.registry.ValidateStrataLink(env, workingState); err != nil { return nil, err } + // WF21 acyclicity (round-15) checked against working state so + // intra-batch causes-LINKs can't close a cycle even when + // individual envelopes pass against pre-batch state. + if err := rt.registry.ValidateCausalAcyclic(env, workingState); err != nil { + return nil, err + } } if err := rt.checkGatesAgainstState(env, &workingState); err != nil { return nil, err diff --git a/internal/operad/validate.go b/internal/operad/validate.go index 551fea1..c8834d2 100644 --- a/internal/operad/validate.go +++ b/internal/operad/validate.go @@ -375,3 +375,71 @@ func containsTypeID(list []graph.TypeID, id graph.TypeID) bool { } return false } + +// ValidateCausalAcyclic enforces WF21 (causes/caused-by) acyclicity for LINK +// rewrites. Called with the kernel read-lock held (state is consistent), in +// the same apply path as ValidateStrataLink. +// +// When a new LINK src --causes--> tgt is proposed, walk forward through +// existing causes-edges starting from tgt. If we reach src, the new LINK +// would close a cycle in the causes/caused-by DAG and is rejected. +// +// Rules: +// 1. Non-WF21 LINKs pass through (no acyclicity constraint applies). +// 2. Non-LINK rewrites (UNLINK, ADD, MUTATE) pass through. +// 3. Self-LINK (src == tgt) is a 1-cycle and is rejected immediately. +// 4. BFS forward from tgt through outgoing edges where RewriteCategory=WF21 +// and SrcPort="causes" (i.e., true causes-direction edges, not the +// reverse caused-by side of the same relation). If env.SrcURN is +// reached, reject. +// +// Round-15 ceremony (T=176): introduced as v314-3-wf21-causes promotion +// companion. Pairs with v314-2-clock-type and v314-4-substrate-property. +// Doctrine anchor: kb/research/spec/07-time-fabric.md §7.3.5 + +// kb/research/spec/05-external-substrates.md §5.1.5/§5.3.5. +func (r *Registry) ValidateCausalAcyclic(env graph.Envelope, state graph.GraphState) error { + if env.RewriteType != graph.LINK { + return nil + } + if env.RewriteCategory != graph.RewriteCategory("WF21") { + return nil + } + if env.SrcURN == env.TgtURN { + return fmt.Errorf("WF21 acyclic: self-LINK %s --causes--> %s would create a 1-cycle", + env.SrcURN, env.TgtURN) + } + + // BFS forward from tgt along outgoing WF21 causes-edges. If we reach src, + // the new edge src --causes--> tgt would close a cycle. + visited := map[graph.URN]bool{env.TgtURN: true} + queue := []graph.URN{env.TgtURN} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for relURN := range state.RelationsBySrc[cur] { + rel, ok := state.Relations[relURN] + if !ok { + continue + } + if rel.RewriteCategory != graph.RewriteCategory("WF21") { + continue + } + // Only follow edges in the canonical causes-direction. The + // reverse caused-by side of the same relation is implicit + // in WF21's port-pair (causes / caused-by) — walking that + // direction would over-detect. + if rel.SrcPort != "causes" { + continue + } + if rel.TgtURN == env.SrcURN { + return fmt.Errorf("WF21 acyclic: adding %s --causes--> %s would close a cycle (path exists: %s --causes...--> %s)", + env.SrcURN, env.TgtURN, env.TgtURN, env.SrcURN) + } + if !visited[rel.TgtURN] { + visited[rel.TgtURN] = true + queue = append(queue, rel.TgtURN) + } + } + } + return nil +} diff --git a/internal/operad/validate_causal_acyclic_test.go b/internal/operad/validate_causal_acyclic_test.go new file mode 100644 index 0000000..0064e7a --- /dev/null +++ b/internal/operad/validate_causal_acyclic_test.go @@ -0,0 +1,213 @@ +package operad + +import ( + "strings" + "testing" + "time" + + "moos/kernel/internal/graph" +) + +// buildAcyclicTestState returns a GraphState with the given WF21 causes-edges +// pre-installed. Each edge is (src, tgt) using SrcPort="causes" / TgtPort="caused-by". +// Nodes are auto-stubbed with type "derivation" so the state is internally consistent. +func buildAcyclicTestState(edges [][2]graph.URN) graph.GraphState { + state := graph.NewGraphState() + urnSet := map[graph.URN]struct{}{} + for _, e := range edges { + urnSet[e[0]] = struct{}{} + urnSet[e[1]] = struct{}{} + } + for urn := range urnSet { + state.Nodes[urn] = graph.Node{ + URN: urn, + TypeID: "derivation", + } + graph.IndexAddNodeByType(state.NodesByType, urn, "derivation") + } + for i, e := range edges { + relURN := graph.URN("urn:moos:rel:test." + string(e[0]) + ".causes." + string(e[1]) + "." + string(rune('a'+i))) + state.Relations[relURN] = graph.Relation{ + URN: relURN, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: e[0], + SrcPort: "causes", + TgtURN: e[1], + TgtPort: "caused-by", + CreatedAt: time.Now(), + } + graph.IndexAddRelationEndpoints(state.RelationsBySrc, state.RelationsByTgt, relURN, e[0], e[1]) + } + return state +} + +func TestValidateCausalAcyclic_NonWF21LinkPasses(t *testing.T) { + reg := EmptyRegistry() + state := graph.NewGraphState() + env := graph.Envelope{ + RewriteType: graph.LINK, + RewriteCategory: graph.WF18, // not WF21 + SrcURN: "urn:moos:claim:a", + TgtURN: "urn:moos:claim:b", + SrcPort: "annotates", + TgtPort: "annotated-by", + } + if err := reg.ValidateCausalAcyclic(env, state); err != nil { + t.Fatalf("non-WF21 LINK should pass: got %v", err) + } +} + +func TestValidateCausalAcyclic_NonLinkPasses(t *testing.T) { + reg := EmptyRegistry() + state := graph.NewGraphState() + for _, rt := range []graph.RewriteType{graph.ADD, graph.MUTATE, graph.UNLINK} { + env := graph.Envelope{ + RewriteType: rt, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: "urn:moos:a", + TgtURN: "urn:moos:b", + } + if err := reg.ValidateCausalAcyclic(env, state); err != nil { + t.Fatalf("non-LINK rewrite %v should pass: got %v", rt, err) + } + } +} + +func TestValidateCausalAcyclic_SelfLinkRejected(t *testing.T) { + reg := EmptyRegistry() + state := graph.NewGraphState() + env := graph.Envelope{ + RewriteType: graph.LINK, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: "urn:moos:claim:self", + TgtURN: "urn:moos:claim:self", + SrcPort: "causes", + TgtPort: "caused-by", + } + err := reg.ValidateCausalAcyclic(env, state) + if err == nil { + t.Fatal("self-LINK should reject as 1-cycle") + } + if !strings.Contains(err.Error(), "1-cycle") { + t.Fatalf("expected '1-cycle' in error, got %v", err) + } +} + +func TestValidateCausalAcyclic_NoPathPasses(t *testing.T) { + // A → B exists; new edge C → D should pass (no path D...→C exists). + reg := EmptyRegistry() + state := buildAcyclicTestState([][2]graph.URN{ + {"urn:moos:claim:a", "urn:moos:claim:b"}, + }) + env := graph.Envelope{ + RewriteType: graph.LINK, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: "urn:moos:claim:c", + TgtURN: "urn:moos:claim:d", + SrcPort: "causes", + TgtPort: "caused-by", + } + if err := reg.ValidateCausalAcyclic(env, state); err != nil { + t.Fatalf("disjoint edge should pass: got %v", err) + } +} + +func TestValidateCausalAcyclic_DirectCycleRejected(t *testing.T) { + // B → A exists; new edge A → B would close a 2-cycle. + reg := EmptyRegistry() + state := buildAcyclicTestState([][2]graph.URN{ + {"urn:moos:claim:b", "urn:moos:claim:a"}, + }) + env := graph.Envelope{ + RewriteType: graph.LINK, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: "urn:moos:claim:a", + TgtURN: "urn:moos:claim:b", + SrcPort: "causes", + TgtPort: "caused-by", + } + err := reg.ValidateCausalAcyclic(env, state) + if err == nil { + t.Fatal("direct cycle should reject") + } + if !strings.Contains(err.Error(), "close a cycle") { + t.Fatalf("expected 'close a cycle' in error, got %v", err) + } +} + +func TestValidateCausalAcyclic_TransitiveCycleRejected(t *testing.T) { + // B → C → D → A exists; new edge A → B would close a 4-cycle. + reg := EmptyRegistry() + state := buildAcyclicTestState([][2]graph.URN{ + {"urn:moos:claim:b", "urn:moos:claim:c"}, + {"urn:moos:claim:c", "urn:moos:claim:d"}, + {"urn:moos:claim:d", "urn:moos:claim:a"}, + }) + env := graph.Envelope{ + RewriteType: graph.LINK, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: "urn:moos:claim:a", + TgtURN: "urn:moos:claim:b", + SrcPort: "causes", + TgtPort: "caused-by", + } + err := reg.ValidateCausalAcyclic(env, state) + if err == nil { + t.Fatal("transitive cycle should reject") + } + if !strings.Contains(err.Error(), "close a cycle") { + t.Fatalf("expected 'close a cycle' in error, got %v", err) + } +} + +func TestValidateCausalAcyclic_DAGForkPasses(t *testing.T) { + // B → C, B → D (fork); new edge A → B should pass — no path B...→A. + reg := EmptyRegistry() + state := buildAcyclicTestState([][2]graph.URN{ + {"urn:moos:claim:b", "urn:moos:claim:c"}, + {"urn:moos:claim:b", "urn:moos:claim:d"}, + }) + env := graph.Envelope{ + RewriteType: graph.LINK, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: "urn:moos:claim:a", + TgtURN: "urn:moos:claim:b", + SrcPort: "causes", + TgtPort: "caused-by", + } + if err := reg.ValidateCausalAcyclic(env, state); err != nil { + t.Fatalf("DAG fork should pass: got %v", err) + } +} + +func TestValidateCausalAcyclic_NonCausesPortIgnored(t *testing.T) { + // A WF21 relation but with non-"causes" SrcPort should not be followed + // (defensive — only true causes-direction edges count). + reg := EmptyRegistry() + state := graph.NewGraphState() + for _, urn := range []graph.URN{"urn:moos:claim:a", "urn:moos:claim:b"} { + state.Nodes[urn] = graph.Node{URN: urn, TypeID: "derivation"} + } + relURN := graph.URN("urn:moos:rel:reverse-only") + state.Relations[relURN] = graph.Relation{ + URN: relURN, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: "urn:moos:claim:b", + SrcPort: "caused-by", // reverse direction, not "causes" + TgtURN: "urn:moos:claim:a", + TgtPort: "causes", + } + graph.IndexAddRelationEndpoints(state.RelationsBySrc, state.RelationsByTgt, relURN, "urn:moos:claim:b", "urn:moos:claim:a") + + env := graph.Envelope{ + RewriteType: graph.LINK, + RewriteCategory: graph.RewriteCategory("WF21"), + SrcURN: "urn:moos:claim:a", + TgtURN: "urn:moos:claim:b", + SrcPort: "causes", + TgtPort: "caused-by", + } + if err := reg.ValidateCausalAcyclic(env, state); err != nil { + t.Fatalf("non-causes port should not be followed; expected pass, got %v", err) + } +}