formal_ai/selection.rs
1//! Issue #559 (R339): registry-driven method selection trace.
2//!
3//! The meta core resolves every atomic work-unit leaf with a *method* through a
4//! single, data-driven authority:
5//! [`MethodRegistry::method_for_route`](crate::method_registry::MethodRegistry::method_for_route)
6//! (R331/R336). The route→method alias link data (R336) lets a leaf whose route
7//! slug differs from the method name still resolve (e.g. `write_program` →
8//! `write_script`). There is no second authority: the legacy hardcoded route
9//! mapper was removed once the registry became the sole dispatch authority, so
10//! this module records *what the registry selects*, not a comparison.
11//!
12//! For every atomic leaf it records, as Links Notation, the method the registry
13//! resolves (or marks the leaf `unresolved` when no method serves it). The
14//! selection step of the algorithm is thus an inspectable white-box artifact:
15//! given a work-unit tree, the trace shows exactly which method each leaf was
16//! dispatched to.
17//!
18//! Recording is governed by [`SelectionMode`]: the default [`SelectionMode::Off`]
19//! records nothing, leaving the trace exactly as it was before this artifact
20//! existed; [`SelectionMode::Record`] emits one `selection` event (a header plus
21//! one record per leaf).
22
23use crate::event_log::EventLog;
24use crate::links_format::format_lino_record;
25use crate::meta_frame::WorkUnit;
26use crate::method_registry::MethodRegistry;
27
28/// Whether the meta core records the per-leaf method-selection artifact. The live
29/// solver always dispatches through the registry; this knob only changes trace
30/// verbosity, never routing or any answer (R13).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum SelectionMode {
33 /// Record nothing, reproducing the pre-artifact trace exactly.
34 #[default]
35 Off,
36 /// Record the registry-resolved method for every atomic leaf.
37 Record,
38}
39
40impl SelectionMode {
41 /// Whether a selection artifact should be emitted at all.
42 #[must_use]
43 pub const fn emits_artifact(self) -> bool {
44 matches!(self, Self::Record)
45 }
46
47 /// The stable slug used in traces and config parsing.
48 #[must_use]
49 pub const fn slug(self) -> &'static str {
50 match self {
51 Self::Off => "off",
52 Self::Record => "record",
53 }
54 }
55
56 /// Parse a slug back into a mode, accepting the canonical spellings.
57 #[must_use]
58 pub fn from_slug(slug: &str) -> Option<Self> {
59 match slug.trim().to_ascii_lowercase().as_str() {
60 "off" => Some(Self::Off),
61 "record" => Some(Self::Record),
62 _ => None,
63 }
64 }
65}
66
67/// The method the registry selects for one atomic work-unit leaf.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct LeafSelection {
70 /// The leaf whose method is being selected.
71 pub unit_id: String,
72 /// Recursion depth of the leaf (0 at the root).
73 pub depth: u8,
74 /// The route slug the leaf carries, when one was recognized.
75 pub route: Option<String>,
76 /// The method the registry resolves for the leaf's route (alias-aware), or
77 /// `None` when no method serves it (an honestly blocked leaf).
78 pub method: Option<String>,
79}
80
81impl LeafSelection {
82 /// Resolve the registry method for one leaf.
83 #[must_use]
84 fn resolve(unit: &WorkUnit, registry: &MethodRegistry) -> Self {
85 let route = unit.route.clone();
86 let method = route
87 .as_deref()
88 .and_then(|route| registry.method_for_route(route))
89 .map(|method| method.name.clone());
90 Self {
91 unit_id: unit.unit_id.clone(),
92 depth: unit.depth,
93 route,
94 method,
95 }
96 }
97
98 /// Whether the registry resolved a method for this leaf.
99 #[must_use]
100 pub const fn is_resolved(&self) -> bool {
101 self.method.is_some()
102 }
103
104 /// Render one leaf's selection as a `leaf_selection` record. A resolved leaf
105 /// names its `method`; an unresolved leaf is marked `unresolved` so a blocked
106 /// leaf is recorded rather than dropped.
107 #[must_use]
108 fn to_links_notation(&self) -> String {
109 let mut pairs: Vec<(&str, String)> = vec![
110 ("record_type", "leaf_selection".to_owned()),
111 ("unit_id", self.unit_id.clone()),
112 ("depth", self.depth.to_string()),
113 ];
114 if let Some(route) = &self.route {
115 pairs.push(("route", route.clone()));
116 }
117 match &self.method {
118 Some(method) => pairs.push(("method", method.clone())),
119 None => pairs.push(("method", "unresolved".to_owned())),
120 }
121 format_lino_record(&self.unit_id, &pairs)
122 }
123}
124
125/// The per-leaf method selection across a work-unit tree.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct MethodSelection {
128 /// The root unit the selection was built for.
129 pub root_id: String,
130 /// One entry per atomic leaf, in source order.
131 pub leaves: Vec<LeafSelection>,
132}
133
134impl MethodSelection {
135 /// Build the selection for a work-unit tree, resolving the registry method for
136 /// every atomic leaf.
137 #[must_use]
138 pub fn for_unit(root: &WorkUnit, registry: &MethodRegistry) -> Self {
139 let mut leaves = Vec::new();
140 collect_leaf_selections(root, registry, &mut leaves);
141 Self {
142 root_id: root.unit_id.clone(),
143 leaves,
144 }
145 }
146
147 /// Number of atomic leaves selected.
148 #[must_use]
149 pub const fn leaf_count(&self) -> usize {
150 self.leaves.len()
151 }
152
153 /// Leaves for which the registry resolved a method.
154 #[must_use]
155 pub fn resolved_count(&self) -> usize {
156 self.leaves.iter().filter(|leaf| leaf.is_resolved()).count()
157 }
158
159 /// Leaves for which no method was resolved (honestly blocked leaves).
160 #[must_use]
161 pub fn unresolved_count(&self) -> usize {
162 self.leaf_count() - self.resolved_count()
163 }
164
165 /// Render the selection as a `selection` header plus one record per leaf.
166 #[must_use]
167 pub fn to_links_notation(&self) -> String {
168 let pairs: Vec<(&str, String)> = vec![
169 ("record_type", "selection".to_owned()),
170 ("root_id", self.root_id.clone()),
171 ("leaf_count", self.leaf_count().to_string()),
172 ("resolved_count", self.resolved_count().to_string()),
173 ("unresolved_count", self.unresolved_count().to_string()),
174 ];
175 let mut out = format_lino_record("selection", &pairs);
176 for leaf in &self.leaves {
177 out.push('\n');
178 out.push_str(&leaf.to_links_notation());
179 }
180 out
181 }
182}
183
184/// Collect a selection entry for every atomic leaf, in source order.
185fn collect_leaf_selections(
186 unit: &WorkUnit,
187 registry: &MethodRegistry,
188 leaves: &mut Vec<LeafSelection>,
189) {
190 if unit.children.is_empty() {
191 leaves.push(LeafSelection::resolve(unit, registry));
192 return;
193 }
194 for child in &unit.children {
195 collect_leaf_selections(child, registry, leaves);
196 }
197}
198
199/// Emit the per-leaf method selection as an optional trace event, gated by `mode`.
200///
201/// Returns `None` when `mode` is [`SelectionMode::Off`], so the default leaves the
202/// trace exactly as it was before this artifact existed. When emitted, it appends
203/// one `selection` event (the serialized header plus every leaf). It is pure
204/// analysis: it changes neither routing nor any answer (R13).
205pub(crate) fn record_selection(
206 log: &mut EventLog,
207 root: &WorkUnit,
208 registry: &MethodRegistry,
209 mode: SelectionMode,
210) -> Option<MethodSelection> {
211 if !mode.emits_artifact() {
212 return None;
213 }
214 let selection = MethodSelection::for_unit(root, registry);
215 log.append("selection", selection.to_links_notation());
216 Some(selection)
217}