|
| 1 | +/- |
| 2 | +Copyright (c) 2026 Lean FRO, LLC. All rights reserved. |
| 3 | +Released under Apache 2.0 license as described in the file LICENSE. |
| 4 | +Authors: Kim Morrison |
| 5 | +-/ |
| 6 | + |
| 7 | +module |
| 8 | + |
| 9 | +public import HexInterval.Experiment.BranchStart |
| 10 | + |
| 11 | +@[expose] public section |
| 12 | + |
| 13 | +/-! |
| 14 | +# Resource-bounded branch trees |
| 15 | +
|
| 16 | +This experiment retains a complete runtime tree while repeatedly running |
| 17 | +ordinary target-directed sessions and expanding accepted split plans. It is |
| 18 | +generic in the fact domain, executable packages, and external policy state. |
| 19 | +
|
| 20 | +The tree is search data, not proof evidence. In particular, a runtime |
| 21 | +contradiction leaf is not a proof of `False`, and a pair of target leaves is |
| 22 | +not a proof of their parent target. The proof frontend must separately replay |
| 23 | +each retained leaf and join checked child evidence through a split schema. |
| 24 | +-/ |
| 25 | + |
| 26 | +namespace Hex.Interval.Experiment.BranchTree |
| 27 | + |
| 28 | +open Propagator PolicySession SemanticReplay TargetRun BranchStart |
| 29 | + |
| 30 | +/-- Which child is being initialized. Policies may use this to fork private |
| 31 | +state differently on the two sides. -/ |
| 32 | +inductive Side where |
| 33 | + | left |
| 34 | + | right |
| 35 | + deriving DecidableEq, Repr |
| 36 | + |
| 37 | +/-- Replaceable pending-leaf order. -/ |
| 38 | +inductive Order where |
| 39 | + | depthFirst |
| 40 | + | breadthFirst |
| 41 | + deriving DecidableEq, Repr |
| 42 | + |
| 43 | +/-- Global tree resources. Child sessions retain their independent engine, |
| 44 | +policy, and payload limits. -/ |
| 45 | +structure Limits where |
| 46 | + branch : BranchStart.Limits |
| 47 | + maxSteps : Nat |
| 48 | + maxSplits : Nat |
| 49 | + maxLeaves : Nat |
| 50 | + leafFuel : Nat |
| 51 | + deriving DecidableEq, Repr |
| 52 | + |
| 53 | +/-- Everything needed to run and split one pending leaf. -/ |
| 54 | +structure Config (Fact PolicyState : Type) : Type 1 where |
| 55 | + factDomain : FactDomain Fact |
| 56 | + packages : Array (Package Fact) |
| 57 | + sessionLimits : PolicySession.Limits |
| 58 | + controller : TargetRun.Controller Fact PolicyState |
| 59 | + splitter : BranchStart.Splitter Fact |
| 60 | + forkPolicy : PolicyState -> Side -> PolicyState |
| 61 | + order : Order |
| 62 | + limits : Limits |
| 63 | + |
| 64 | +/-- Stable index into the append-only node array. -/ |
| 65 | +structure TreeId where |
| 66 | + index : Nat |
| 67 | + deriving DecidableEq, Repr |
| 68 | + |
| 69 | +/-- Exact input, scope, depth, and private policy state of one leaf. -/ |
| 70 | +structure Leaf (Fact PolicyState : Type) where |
| 71 | + scope : Propagator.Policy.ScopeId |
| 72 | + depth : Nat |
| 73 | + input : CheckerInput Fact |
| 74 | + policyState : PolicyState |
| 75 | + |
| 76 | +/-- A leaf whose policy session can still be run. -/ |
| 77 | +structure Job (Fact PolicyState : Type) : Type 1 where |
| 78 | + scope : Propagator.Policy.ScopeId |
| 79 | + depth : Nat |
| 80 | + input : CheckerInput Fact |
| 81 | + policyState : PolicyState |
| 82 | + session : PolicySession.Session Fact |
| 83 | + |
| 84 | +/-- Why a split result was retained as a blocked leaf. -/ |
| 85 | +inductive Blocked where |
| 86 | + | splitRejected (error : BranchStart.Error) |
| 87 | + | splitLimit |
| 88 | + | leafLimit |
| 89 | + deriving DecidableEq, Repr |
| 90 | + |
| 91 | +/-- Terminal runtime data for a leaf. None of these constructors is proof |
| 92 | +evidence. -/ |
| 93 | +inductive LeafEnd (Fact PolicyState : Type) : Type 1 |
| 94 | + | result (run : TargetRun.Result Fact PolicyState) |
| 95 | + | blocked (run : TargetRun.Result Fact PolicyState) (reason : Blocked) |
| 96 | + | startError (error : PolicySession.StartError) |
| 97 | + |
| 98 | +/-- One retained runtime-tree node. A split node stores the exact validated |
| 99 | +child snapshots and both child identities, even if a child session failed to |
| 100 | +start. -/ |
| 101 | +inductive Node (Fact PolicyState : Type) : Type 1 |
| 102 | + | pending (job : Job Fact PolicyState) |
| 103 | + | leaf (source : Leaf Fact PolicyState) (ending : LeafEnd Fact PolicyState) |
| 104 | + | split (source : Leaf Fact PolicyState) |
| 105 | + (run : TargetRun.Result Fact PolicyState) |
| 106 | + (children : BranchStart.Children Fact) (left right : TreeId) |
| 107 | + |
| 108 | +/-- Monotone retained state for one tree. `leaves` counts current leaves, so |
| 109 | +an accepted binary split increases it by exactly one. -/ |
| 110 | +structure State (Fact PolicyState : Type) : Type 1 where |
| 111 | + nodes : Array (Node Fact PolicyState) |
| 112 | + frontier : List TreeId |
| 113 | + branch : BranchStart.State |
| 114 | + splits : Nat |
| 115 | + leaves : Nat |
| 116 | + steps : Nat |
| 117 | + |
| 118 | +/-- Failure before a coherent root tree exists. -/ |
| 119 | +inductive StartError where |
| 120 | + | leafLimit |
| 121 | + | scopeLimit |
| 122 | + | unknownTarget |
| 123 | + | session (error : PolicySession.StartError) |
| 124 | + deriving DecidableEq, Repr |
| 125 | + |
| 126 | +/-- An append-only tree should only schedule valid pending nodes. -/ |
| 127 | +inductive Error where |
| 128 | + | missingNode (id : TreeId) |
| 129 | + | settledNode (id : TreeId) |
| 130 | + deriving DecidableEq, Repr |
| 131 | + |
| 132 | +def sourceOf (job : Job Fact PolicyState) : Leaf Fact PolicyState := |
| 133 | + { scope := job.scope |
| 134 | + depth := job.depth |
| 135 | + input := job.input |
| 136 | + policyState := job.policyState } |
| 137 | + |
| 138 | +/-- A tree is settled exactly when it has no runnable leaf. It may still |
| 139 | +contain blocked, unfinished, or failed leaves. -/ |
| 140 | +def State.settled (state : State Fact PolicyState) : Bool := |
| 141 | + state.frontier.isEmpty |
| 142 | + |
| 143 | +/-- The global processing budget may leave a nonempty frontier. -/ |
| 144 | +def State.stepLimited (limits : Limits) (state : State Fact PolicyState) : Bool := |
| 145 | + state.steps >= limits.maxSteps && !state.frontier.isEmpty |
| 146 | + |
| 147 | +/-- Build a coherent root session from the exact caller input. -/ |
| 148 | +def start (config : Config Fact PolicyState) (scope : Propagator.Policy.ScopeId) |
| 149 | + (input : CheckerInput Fact) (policyState : PolicyState) : |
| 150 | + Except StartError (State Fact PolicyState) := do |
| 151 | + if config.limits.maxLeaves = 0 then throw .leafLimit |
| 152 | + if config.limits.branch.maxScopes = 0 then throw .scopeLimit |
| 153 | + if (input.baseProgram.node? input.target.node).isNone then |
| 154 | + throw StartError.unknownTarget |
| 155 | + let session <- |
| 156 | + match PolicySession.Session.start config.factDomain input.baseProgram |
| 157 | + config.packages input.initialFacts config.sessionLimits scope with |
| 158 | + | .ok session => pure session |
| 159 | + | .error error => throw (.session error) |
| 160 | + let root : Job Fact PolicyState := |
| 161 | + { scope, depth := 0, input, policyState, session } |
| 162 | + pure |
| 163 | + { nodes := #[.pending root] |
| 164 | + frontier := [{ index := 0 }] |
| 165 | + branch := BranchStart.State.start scope |
| 166 | + splits := 0 |
| 167 | + leaves := 1 |
| 168 | + steps := 0 } |
| 169 | + |
| 170 | +def schedule (order : Order) (rest fresh : List TreeId) : List TreeId := |
| 171 | + match order with |
| 172 | + | .depthFirst => fresh ++ rest |
| 173 | + | .breadthFirst => rest ++ fresh |
| 174 | + |
| 175 | +def childNode (config : Config Fact PolicyState) (side : Side) |
| 176 | + (scope : Propagator.Policy.ScopeId) (depth : Nat) (input : CheckerInput Fact) |
| 177 | + (policyState : PolicyState) : Node Fact PolicyState × Bool := |
| 178 | + let childState := config.forkPolicy policyState side |
| 179 | + let source : Leaf Fact PolicyState := { scope, depth, input, policyState := childState } |
| 180 | + match PolicySession.Session.start config.factDomain input.baseProgram |
| 181 | + config.packages input.initialFacts config.sessionLimits scope with |
| 182 | + | .ok session => |
| 183 | + (.pending |
| 184 | + { scope := source.scope |
| 185 | + depth := source.depth |
| 186 | + input := source.input |
| 187 | + policyState := source.policyState |
| 188 | + session }, true) |
| 189 | + | .error error => (.leaf source (.startError error), false) |
| 190 | + |
| 191 | +def retainLeaf (state : State Fact PolicyState) (id : TreeId) |
| 192 | + (source : Leaf Fact PolicyState) (ending : LeafEnd Fact PolicyState) |
| 193 | + (rest : List TreeId) : State Fact PolicyState := |
| 194 | + { state with |
| 195 | + nodes := state.nodes.set! id.index (.leaf source ending) |
| 196 | + frontier := rest |
| 197 | + steps := state.steps + 1 } |
| 198 | + |
| 199 | +/-- Process one pending leaf. Split rejection and tree-resource exhaustion |
| 200 | +are terminal leaf data; only a malformed internal frontier returns `Error`. -/ |
| 201 | +def step [DecidableEq Fact] (config : Config Fact PolicyState) |
| 202 | + (state : State Fact PolicyState) : Except Error (State Fact PolicyState) := do |
| 203 | + if state.steps >= config.limits.maxSteps then return state |
| 204 | + let some id := state.frontier.head? | return state |
| 205 | + let rest := state.frontier.tail |
| 206 | + let some node := state.nodes[id.index]? | throw (.missingNode id) |
| 207 | + let .pending job := node | throw (.settledNode id) |
| 208 | + let source := sourceOf job |
| 209 | + let run := TargetRun.drive config.factDomain job.input.target.node |
| 210 | + job.input.target.fact config.controller config.limits.leafFuel |
| 211 | + job.session job.policyState |
| 212 | + let .split plan := run.stop | |
| 213 | + return retainLeaf state id source (.result run) rest |
| 214 | + if state.splits >= config.limits.maxSplits then |
| 215 | + return retainLeaf state id source (.blocked run .splitLimit) rest |
| 216 | + if state.leaves + 1 > config.limits.maxLeaves then |
| 217 | + return retainLeaf state id source (.blocked run .leafLimit) rest |
| 218 | + match BranchStart.prepare config.limits.branch state.branch job.depth |
| 219 | + run.session plan job.input.target config.splitter with |
| 220 | + | .error error => |
| 221 | + pure (retainLeaf state id source (.blocked run (.splitRejected error)) rest) |
| 222 | + | .ok (branch, children) => |
| 223 | + let leftId : TreeId := { index := state.nodes.size } |
| 224 | + let rightId : TreeId := { index := state.nodes.size + 1 } |
| 225 | + let (leftNode, leftPending) := childNode config .left children.leftScope |
| 226 | + children.depth children.left run.policyState |
| 227 | + let (rightNode, rightPending) := childNode config .right children.rightScope |
| 228 | + children.depth children.right run.policyState |
| 229 | + let fresh := |
| 230 | + (if leftPending then [leftId] else []) ++ |
| 231 | + (if rightPending then [rightId] else []) |
| 232 | + pure |
| 233 | + { nodes := (state.nodes.set! id.index |
| 234 | + (.split source run children leftId rightId)).push leftNode |>.push rightNode |
| 235 | + frontier := schedule config.order rest fresh |
| 236 | + branch |
| 237 | + splits := state.splits + 1 |
| 238 | + leaves := state.leaves + 1 |
| 239 | + steps := state.steps + 1 } |
| 240 | + |
| 241 | +/-- Run for at most the caller fuel and never beyond the retained global step |
| 242 | +budget. A nonempty frontier in the result is an honest partial tree. -/ |
| 243 | +def runFrom [DecidableEq Fact] (config : Config Fact PolicyState) : |
| 244 | + Nat -> State Fact PolicyState -> Except Error (State Fact PolicyState) |
| 245 | + | 0, state => pure state |
| 246 | + | fuel + 1, state => |
| 247 | + if state.settled || state.steps >= config.limits.maxSteps then pure state |
| 248 | + else do |
| 249 | + let state ← step config state |
| 250 | + runFrom config fuel state |
| 251 | + |
| 252 | +termination_by fuel _ => fuel |
| 253 | + |
| 254 | +/-- Consume the configured global processing budget. -/ |
| 255 | +def run [DecidableEq Fact] (config : Config Fact PolicyState) |
| 256 | + (state : State Fact PolicyState) : Except Error (State Fact PolicyState) := |
| 257 | + runFrom config config.limits.maxSteps state |
| 258 | + |
| 259 | +end Hex.Interval.Experiment.BranchTree |
0 commit comments