formal_ai/meta_construction.rs
1//! Issue #559: the upward construction pass and the `recursion_mode` knob.
2//!
3//! The meta core's downward pass ([`crate::meta_frame::WorkUnit`]) decomposes a
4//! request into a bounded work-unit tree, and the downward reasoning
5//! ([`crate::meta_reasoning`], R337) explains *why* each unit was split or judged
6//! atomic. Decomposition is only half of a recursive algorithm: the other half is
7//! **construction** — composing the children's results back up into the parent's
8//! answer, leaf to root. This module records that upward pass explicitly.
9//!
10//! The construction is a post-order (bottom-up) walk of the same tree: each leaf
11//! is constructed directly from the method that resolves it (the base case), and
12//! each parent is constructed by composing its already-constructed children in
13//! source order (the recursive case), terminating at the root. Serialized to
14//! Links Notation as `construction_step` records under one `upward_construction`
15//! header, it makes the compositional direction as inspectable as the
16//! decompositional one.
17//!
18//! Which directions the meta core emits is governed by [`RecursionMode`]:
19//!
20//! * [`RecursionMode::Down`] (the default) — emit the downward reasoning only, so
21//! the trace is exactly what shipped before this knob existed (behavior-
22//! preserving, R13);
23//! * [`RecursionMode::Up`] — emit the upward construction only;
24//! * [`RecursionMode::Both`] — emit both directions.
25//!
26//! The work-unit decomposition events (`work_unit:enter` / `work_unit:exit`) are
27//! structural and always emitted; the knob gates only the directional *reasoning*
28//! artifacts, none of which change routing or the answer.
29
30use crate::event_log::EventLog;
31use crate::links_format::format_lino_record;
32use crate::meta_frame::WorkUnit;
33use crate::method_registry::MethodRegistry;
34
35/// Which direction(s) of recursive reasoning the meta core emits.
36///
37/// `Down` is the default and reproduces the pre-knob trace exactly, so enabling
38/// the upward pass is always an explicit opt-in.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum RecursionMode {
41 /// Emit the downward decomposition reasoning only (default, behavior-preserving).
42 #[default]
43 Down,
44 /// Emit the upward construction reasoning only.
45 Up,
46 /// Emit both the downward and the upward reasoning.
47 Both,
48}
49
50impl RecursionMode {
51 /// Whether the downward decomposition reasoning (R337) should be emitted.
52 #[must_use]
53 pub const fn emits_downward(self) -> bool {
54 matches!(self, Self::Down | Self::Both)
55 }
56
57 /// Whether the upward construction pass should be emitted.
58 #[must_use]
59 pub const fn emits_upward(self) -> bool {
60 matches!(self, Self::Up | Self::Both)
61 }
62
63 /// The stable slug used in traces and config parsing.
64 #[must_use]
65 pub const fn slug(self) -> &'static str {
66 match self {
67 Self::Down => "down",
68 Self::Up => "up",
69 Self::Both => "both",
70 }
71 }
72
73 /// Parse a slug back into a mode, accepting the canonical spellings.
74 #[must_use]
75 pub fn from_slug(slug: &str) -> Option<Self> {
76 match slug.trim().to_ascii_lowercase().as_str() {
77 "down" => Some(Self::Down),
78 "up" => Some(Self::Up),
79 "both" => Some(Self::Both),
80 _ => None,
81 }
82 }
83}
84
85/// How one work unit's answer is constructed during the upward pass.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ConstructionStep {
88 /// The unit whose answer this step constructs.
89 pub unit_id: String,
90 /// Recursion depth of the unit (0 at the root).
91 pub depth: u8,
92 /// Post-order position (1-based): children are always constructed before
93 /// their parent, and the root is last.
94 pub order: usize,
95 /// `leaf_method` (base case) or `compose` (recursive case).
96 pub kind: String,
97 /// The method whose result a leaf is constructed from, when one resolves.
98 pub method: Option<String>,
99 /// The child unit ids composed into a parent's answer, in source order.
100 pub inputs: Vec<String>,
101 /// Why the answer is constructed this way (human-readable).
102 pub rationale: String,
103}
104
105/// The upward construction pass: a post-order list of construction steps.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct UpwardConstruction {
108 /// The root unit the construction terminates at.
109 pub root_id: String,
110 /// One step per unit, in post-order (leaves first, root last).
111 pub steps: Vec<ConstructionStep>,
112}
113
114impl UpwardConstruction {
115 /// Build the upward construction for a work-unit tree, resolving leaf methods
116 /// through the same `method_for_route` bridge the evidence join uses.
117 #[must_use]
118 pub fn for_unit(root: &WorkUnit, registry: &MethodRegistry) -> Self {
119 let mut steps = Vec::new();
120 visit_post_order(root, registry, &mut steps);
121 Self {
122 root_id: root.unit_id.clone(),
123 steps,
124 }
125 }
126
127 /// Number of construction steps (one per unit).
128 #[must_use]
129 pub const fn step_count(&self) -> usize {
130 self.steps.len()
131 }
132
133 /// Render the construction as a header plus one record per step.
134 #[must_use]
135 pub fn to_links_notation(&self) -> String {
136 let header = format_lino_record(
137 "upward_construction",
138 &[
139 ("record_type", "upward_construction".to_owned()),
140 ("root_id", self.root_id.clone()),
141 ("step_count", self.steps.len().to_string()),
142 ],
143 );
144 let mut out = header;
145 for step in &self.steps {
146 out.push('\n');
147 out.push_str(&step.to_links_notation());
148 }
149 out
150 }
151}
152
153impl ConstructionStep {
154 /// Render one construction step as a `construction_step` record.
155 #[must_use]
156 fn to_links_notation(&self) -> String {
157 let mut pairs: Vec<(&str, String)> = vec![
158 ("record_type", "construction_step".to_owned()),
159 ("unit_id", self.unit_id.clone()),
160 ("depth", self.depth.to_string()),
161 ("order", self.order.to_string()),
162 ("kind", self.kind.clone()),
163 ("rationale", self.rationale.clone()),
164 ];
165 if let Some(method) = &self.method {
166 pairs.push(("method", method.clone()));
167 }
168 for input in &self.inputs {
169 pairs.push(("input", input.clone()));
170 }
171 format_lino_record(&self.unit_id, &pairs)
172 }
173}
174
175/// Append each unit's construction step in post-order (children before parent).
176fn visit_post_order(unit: &WorkUnit, registry: &MethodRegistry, steps: &mut Vec<ConstructionStep>) {
177 for child in &unit.children {
178 visit_post_order(child, registry, steps);
179 }
180 let order = steps.len() + 1;
181 let step = if unit.children.is_empty() {
182 let method = unit
183 .route
184 .as_ref()
185 .and_then(|route| registry.method_for_route(route))
186 .map(|method| method.name.clone());
187 let rationale = method.as_ref().map_or_else(
188 || {
189 "Base case: no method resolves this leaf, so its answer is a blocked \
190 marker — nothing is constructed."
191 .to_owned()
192 },
193 |method| {
194 format!(
195 "Base case: construct this leaf's answer directly from the result of \
196 method `{method}`."
197 )
198 },
199 );
200 ConstructionStep {
201 unit_id: unit.unit_id.clone(),
202 depth: unit.depth,
203 order,
204 kind: "leaf_method".to_owned(),
205 method,
206 inputs: Vec::new(),
207 rationale,
208 }
209 } else {
210 let inputs = unit
211 .children
212 .iter()
213 .map(|child| child.unit_id.clone())
214 .collect::<Vec<_>>();
215 let rationale = format!(
216 "Recursive case: compose the {} already-constructed children in source order \
217 into this unit's answer.",
218 inputs.len()
219 );
220 ConstructionStep {
221 unit_id: unit.unit_id.clone(),
222 depth: unit.depth,
223 order,
224 kind: "compose".to_owned(),
225 method: None,
226 inputs,
227 rationale,
228 }
229 };
230 steps.push(step);
231}
232
233/// Emit the upward construction pass as a trace-only event, gated by `mode`.
234///
235/// Returns `None` when `mode` does not request the upward direction, so the
236/// default ([`RecursionMode::Down`]) leaves the trace exactly as it was before
237/// this pass existed (R13). When emitted, it appends one `upward_construction`
238/// event (the serialized header plus every step) and a compact
239/// `upward_construction:steps`.
240pub(crate) fn record_upward_construction(
241 log: &mut EventLog,
242 root: &WorkUnit,
243 registry: &MethodRegistry,
244 mode: RecursionMode,
245) -> Option<UpwardConstruction> {
246 if !mode.emits_upward() {
247 return None;
248 }
249 let construction = UpwardConstruction::for_unit(root, registry);
250 log.append("upward_construction", construction.to_links_notation());
251 log.append(
252 "upward_construction:steps",
253 construction.step_count().to_string(),
254 );
255 Some(construction)
256}