Skip to main content

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}