-
Notifications
You must be signed in to change notification settings - Fork 0
feat(operad+kernel): WF21 causes ValidateCausalAcyclic + round-15 ceremony #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+404
to
+406
|
||||||||||||||||
| 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:] | ||||||||||||||||
|
Comment on lines
+416
to
+418
|
||||||||||||||||
| for len(queue) > 0 { | |
| cur := queue[0] | |
| queue = queue[1:] | |
| head := 0 | |
| for head < len(queue) { | |
| cur := queue[head] | |
| head++ |
Copilot
AI
Apr 26, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ValidateCausalAcyclic iterates via state.RelationsBySrc[cur] directly. Per GraphState docs, indexes can be nil/uninitialized (e.g., state loaded from JSON without Rebuild), and read paths are expected to tolerate that. As written, a nil RelationsBySrc will make the BFS see no outgoing edges and may incorrectly allow a cycle. Prefer using the GraphState accessor (RelationsFrom) or falling back to scanning state.Relations when the index is nil.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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], | ||
|
Comment on lines
+30
to
+33
|
||
| SrcPort: "causes", | ||
| TgtURN: e[1], | ||
| TgtPort: "caused-by", | ||
| CreatedAt: time.Now(), | ||
| } | ||
| graph.IndexAddRelationEndpoints(state.RelationsBySrc, state.RelationsByTgt, relURN, e[0], e[1]) | ||
| } | ||
| return state | ||
|
Comment on lines
+14
to
+41
|
||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The doc comment says this is called with the kernel read-lock held, but the current apply paths call registry LINK validations under rt.mu.Lock() (write-lock). To avoid misleading future callers, consider rewording to something like “called with the kernel lock held / state is consistent” rather than specifying a read-lock.