Skip to main content

newt_core/
ffi_manifest.rs

1//! FFI-introspection manifest — the **knowledge-base** support part (#74).
2//!
3//! The cross-family forensic (`docs/findings/2026-06-14-fabrication-is-sampling-
4//! not-information-loss.md`) showed a model fabricates a PyO3 import surface —
5//! `import newt_core` (the *crate* name) instead of the real
6//! `newt_agent._newt_agent.core` — even though it *read* the binding source that
7//! declares the path. The fix is not to make it re-read: it is to hand the model
8//! the authoritative `{crate → import_path}` as a compact **structured fact** that
9//! survives compression where 20k of verbatim Rust cannot.
10//!
11//! This module extracts that fact from the PyO3 binding sources. Each crate's
12//! `src/pyo3_module.rs` declares its Python path on every `#[pyclass(module =
13//! "…")]`, its exported classes via that `name = "…"`, and any non-class exports
14//! via `m.add("…", …)`. We harvest those into an [`FfiManifest`] that can be:
15//! - rendered as an injectable [`render_block`](FfiManifest::render_block) for the
16//!   model's working set (the knowledge-base part), and
17//! - turned into the authoritative [`SymbolIndex`](FfiManifest::to_symbol_index)
18//!   the verify oracle resolves against (replacing the hand-maintained
19//!   `python_surface.json` map in `pack_pyo3_corpus.sh`).
20//!
21//! Regex-floor, like [`crate::symbols`]: a `#[pyclass(module = …)]` declaration on
22//! a normal attribute is in scope; macro-generated or `cfg`-hidden bindings are
23//! not, until a syn/tree-sitter upgrade.
24
25use crate::symbols::SymbolIndex;
26use regex::Regex;
27use std::path::Path;
28
29/// One PyO3-bound crate's Python import surface, extracted from its binding source.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct FfiModule {
32    /// The source label — the Rust crate name (e.g. `"newt-core"`).
33    pub source: String,
34    /// The authoritative Python import path (e.g. `"newt_agent._newt_agent.core"`),
35    /// taken from the `#[pyclass(module = "…")]` attribute.
36    pub import_path: String,
37    /// Exported Python symbol names (pyclass `name = "…"` + `m.add("…", …)`),
38    /// sorted and de-duplicated for a deterministic surface.
39    pub symbols: Vec<String>,
40}
41
42/// The extracted import surface of a set of PyO3-bound crates — the
43/// knowledge-base fact handed to a model and the ground truth scored against.
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct FfiManifest {
46    /// One entry per bound crate, in input order.
47    pub modules: Vec<FfiModule>,
48}
49
50impl FfiModule {
51    /// Extract a module from one PyO3 binding source. Returns `None` if the source
52    /// declares no `#[pyclass(module = "…")]` — i.e. it is not a bound surface we
53    /// can name authoritatively.
54    #[must_use]
55    pub fn from_source(source_label: &str, src: &str) -> Option<Self> {
56        // `#[pyclass( … )]` attribute bodies, single- or multi-line.
57        let pyclass_re = Regex::new(r"(?s)#\[pyclass\((.*?)\)\]").unwrap();
58        let module_re = Regex::new(r#"\bmodule\s*=\s*"([^"]+)""#).unwrap();
59        let name_re = Regex::new(r#"\bname\s*=\s*"([^"]+)""#).unwrap();
60        // `m.add("Name", …)` — non-class exports (exceptions, constants).
61        let add_re = Regex::new(r#"\.add\(\s*"([^"]+)""#).unwrap();
62
63        let mut import_path: Option<String> = None;
64        let mut symbols: Vec<String> = Vec::new();
65
66        for caps in pyclass_re.captures_iter(src) {
67            let body = &caps[1];
68            if let Some(m) = module_re.captures(body) {
69                // All pyclasses in a crate declare the same path; first wins.
70                import_path.get_or_insert_with(|| m[1].to_string());
71            }
72            if let Some(n) = name_re.captures(body) {
73                symbols.push(n[1].to_string());
74            }
75        }
76
77        let import_path = import_path?;
78        for caps in add_re.captures_iter(src) {
79            symbols.push(caps[1].to_string());
80        }
81        symbols.sort();
82        symbols.dedup();
83
84        Some(Self {
85            source: source_label.to_string(),
86            import_path,
87            symbols,
88        })
89    }
90}
91
92impl FfiManifest {
93    /// Build a manifest from labelled binding sources `(crate_name, source)`.
94    /// Sources with no bound surface are skipped.
95    #[must_use]
96    pub fn from_sources<'a>(sources: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
97        let modules = sources
98            .into_iter()
99            .filter_map(|(label, src)| FfiModule::from_source(label, src))
100            .collect();
101        Self { modules }
102    }
103
104    /// Build a manifest by discovering `<crate>/src/pyo3_module.rs` under a
105    /// workspace root — each top-level subdirectory is a crate, named by its
106    /// directory. Crates without a readable binding are skipped; the result is
107    /// deterministic (crates in sorted directory order).
108    pub fn from_workspace(root: &Path) -> std::io::Result<Self> {
109        let mut found: Vec<(String, String)> = Vec::new();
110        for entry in std::fs::read_dir(root)? {
111            let entry = entry?;
112            if !entry.file_type()?.is_dir() {
113                continue;
114            }
115            let binding = entry.path().join("src").join("pyo3_module.rs");
116            if let Ok(src) = std::fs::read_to_string(&binding) {
117                found.push((entry.file_name().to_string_lossy().into_owned(), src));
118            }
119        }
120        found.sort();
121        Ok(Self::from_sources(
122            found.iter().map(|(l, s)| (l.as_str(), s.as_str())),
123        ))
124    }
125
126    /// Number of bound crates in the manifest.
127    #[must_use]
128    pub fn len(&self) -> usize {
129        self.modules.len()
130    }
131
132    /// Whether the manifest is empty (no bound surface extracted).
133    #[must_use]
134    pub fn is_empty(&self) -> bool {
135        self.modules.is_empty()
136    }
137
138    /// Render the manifest as an injectable, model-facing knowledge-base block —
139    /// the authoritative import paths as a compact structured fact. Empty manifest
140    /// renders to an empty string (inject nothing).
141    #[must_use]
142    pub fn render_block(&self) -> String {
143        if self.modules.is_empty() {
144            return String::new();
145        }
146        let mut out = String::from(
147            "[PYO3 IMPORT SURFACE — authoritative; import these exact paths, \
148             do not infer a path from the crate name]\n",
149        );
150        for m in &self.modules {
151            out.push_str("- ");
152            out.push_str(&m.source);
153            out.push_str(" → ");
154            out.push_str(&m.import_path);
155            if !m.symbols.is_empty() {
156                out.push_str("  (");
157                out.push_str(&m.symbols.join(", "));
158                out.push(')');
159            }
160            out.push('\n');
161        }
162        out
163    }
164
165    /// The set of known module paths — the project surface the verify gate
166    /// resolves against. For each bound crate this is every dotted ancestor of
167    /// the native `pkg._ext.sub` path **and** of the stitched public `pkg.sub`
168    /// path the umbrella re-exports: both are valid imports, so a leaf-exact gate
169    /// must accept either and only reject genuinely-absent modules.
170    #[must_use]
171    pub fn known_modules(&self) -> std::collections::BTreeSet<String> {
172        let mut set = std::collections::BTreeSet::new();
173        for m in &self.modules {
174            for ancestor in dotted_ancestors(&m.import_path) {
175                set.insert(ancestor);
176            }
177            let stitched = public_stitched_path(&m.import_path);
178            if !stitched.is_empty() && stitched != m.import_path {
179                for ancestor in dotted_ancestors(&stitched) {
180                    set.insert(ancestor);
181                }
182            }
183        }
184        set
185    }
186
187    /// Build the authoritative [`SymbolIndex`] the verify oracle resolves against:
188    /// every import path (and its dotted ancestors — `newt_agent`,
189    /// `newt_agent._newt_agent`, …, all real packages) is a known module, and each
190    /// exported symbol is registered under its module.
191    #[must_use]
192    pub fn to_symbol_index(&self) -> SymbolIndex {
193        let mut index = SymbolIndex::new();
194        for m in &self.modules {
195            for ancestor in dotted_ancestors(&m.import_path) {
196                index.add_module(ancestor);
197            }
198            for symbol in &m.symbols {
199                index.add_symbol(m.import_path.clone(), symbol.clone());
200            }
201        }
202        index
203    }
204}
205
206/// The public "stitched" form of a native extension path: drop the dotted
207/// segments that start with `_` (the private compiled-extension layer the
208/// umbrella package re-exports over). `"newt_agent._newt_agent.core"` →
209/// `"newt_agent.core"`; a path with no private segment is returned unchanged.
210fn public_stitched_path(path: &str) -> String {
211    path.split('.')
212        .filter(|seg| !seg.starts_with('_'))
213        .collect::<Vec<_>>()
214        .join(".")
215}
216
217/// Every dotted prefix of a module path, longest last:
218/// `"a.b.c"` → `["a", "a.b", "a.b.c"]`. Each is a real, importable package.
219fn dotted_ancestors(path: &str) -> Vec<String> {
220    let mut acc = String::new();
221    let mut out = Vec::new();
222    for part in path.split('.') {
223        if !acc.is_empty() {
224            acc.push('.');
225        }
226        acc.push_str(part);
227        out.push(acc.clone());
228    }
229    out
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::symbols::{Reference, Resolution};
236
237    // A faithful slice of a real `pyo3_module.rs` (newt-core): a multi-line
238    // pyclass attr, a single-line one, and `m.add` / `create_exception` exports.
239    const NEWT_CORE_SRC: &str = r#"
240create_exception!(_newt_agent, PyNewtError, PyException);
241
242#[pyclass(
243    name = "Tier",
244    module = "newt_agent._newt_agent.core",
245    eq,
246)]
247pub struct PyTier;
248
249#[pyclass(name = "Router", module = "newt_agent._newt_agent.core")]
250pub struct PyRouter;
251
252pub fn register(py: Python<'_>, parent: &Bound<'_, PyModule>) -> PyResult<()> {
253    let m = PyModule::new(py, "core")?;
254    m.add_class::<PyTier>()?;
255    m.add_class::<PyRouter>()?;
256    m.add("NewtError", py.get_type::<PyNewtError>())?;
257    Ok(())
258}
259"#;
260
261    const NEWT_DATA_SRC: &str = r#"
262#[pyclass(name = "DataStore", module = "newt_agent._newt_agent.data")]
263pub struct PyDataStore;
264"#;
265
266    #[test]
267    fn extracts_import_path_and_symbols() {
268        let m = FfiModule::from_source("newt-core", NEWT_CORE_SRC).unwrap();
269        assert_eq!(m.source, "newt-core");
270        assert_eq!(m.import_path, "newt_agent._newt_agent.core");
271        // pyclass names + the m.add export, sorted + deduped.
272        assert_eq!(m.symbols, vec!["NewtError", "Router", "Tier"]);
273    }
274
275    #[test]
276    fn source_without_pyclass_module_is_none() {
277        assert!(FfiModule::from_source("plain", "pub fn f() {}").is_none());
278        // a pyclass with no `module =` is not authoritatively placeable
279        assert!(FfiModule::from_source("nomod", r#"#[pyclass(name = "X")] struct X;"#).is_none());
280    }
281
282    #[test]
283    fn manifest_skips_unbound_sources() {
284        let manifest = FfiManifest::from_sources([
285            ("newt-core", NEWT_CORE_SRC),
286            ("plain", "pub fn nothing() {}"),
287            ("newt-data", NEWT_DATA_SRC),
288        ]);
289        assert_eq!(manifest.len(), 2);
290        assert!(!manifest.is_empty());
291        let paths: Vec<&str> = manifest
292            .modules
293            .iter()
294            .map(|m| m.import_path.as_str())
295            .collect();
296        assert_eq!(
297            paths,
298            vec!["newt_agent._newt_agent.core", "newt_agent._newt_agent.data"]
299        );
300    }
301
302    #[test]
303    fn from_workspace_discovers_bindings() {
304        let tmp = tempfile::tempdir().unwrap();
305        // <root>/newt-core/src/pyo3_module.rs and <root>/newt-data/src/...
306        for (crate_dir, src) in [("newt-core", NEWT_CORE_SRC), ("newt-data", NEWT_DATA_SRC)] {
307            let dir = tmp.path().join(crate_dir).join("src");
308            std::fs::create_dir_all(&dir).unwrap();
309            std::fs::write(dir.join("pyo3_module.rs"), src).unwrap();
310        }
311        // a non-crate dir and a crate without a binding are both ignored
312        std::fs::create_dir_all(tmp.path().join("docs")).unwrap();
313        std::fs::create_dir_all(tmp.path().join("newt-empty").join("src")).unwrap();
314
315        let manifest = FfiManifest::from_workspace(tmp.path()).unwrap();
316        assert_eq!(manifest.len(), 2);
317        // deterministic (sorted) order: newt-core before newt-data
318        assert_eq!(manifest.modules[0].source, "newt-core");
319        assert_eq!(
320            manifest.modules[0].import_path,
321            "newt_agent._newt_agent.core"
322        );
323        assert_eq!(manifest.modules[1].source, "newt-data");
324    }
325
326    #[test]
327    fn render_block_is_the_injectable_fact() {
328        let manifest = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]);
329        let block = manifest.render_block();
330        assert!(block.contains("authoritative"));
331        assert!(block.contains("newt-core → newt_agent._newt_agent.core"));
332        assert!(block.contains("NewtError, Router, Tier"));
333    }
334
335    #[test]
336    fn empty_manifest_renders_nothing() {
337        let manifest = FfiManifest::default();
338        assert!(manifest.is_empty());
339        assert_eq!(manifest.render_block(), "");
340    }
341
342    #[test]
343    fn symbol_index_resolves_real_and_flags_fabricated() {
344        let manifest = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]);
345        let index = manifest.to_symbol_index();
346
347        // the real submodule path and its ancestors resolve
348        assert!(index.has_module("newt_agent._newt_agent.core"));
349        assert!(index.has_module("newt_agent._newt_agent"));
350        assert!(index.has_module("newt_agent"));
351        // a real symbol under the real module resolves
352        assert_eq!(
353            index.resolve(&Reference::import_from(
354                "newt_agent._newt_agent.core",
355                "Router",
356                1
357            )),
358            Resolution::Resolved
359        );
360        // the fabricated crate-name module does NOT exist
361        assert_eq!(
362            index.resolve(&Reference::import_module("newt_core", 1)),
363            Resolution::UnknownModule
364        );
365    }
366
367    #[test]
368    fn known_modules_covers_native_and_stitched_paths() {
369        // both the native `pkg._ext.sub` and the stitched public `pkg.sub` are
370        // valid imports, so a leaf-exact gate must not revert either.
371        let known = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]).known_modules();
372        assert!(known.contains("newt_agent._newt_agent.core"), "native path");
373        assert!(known.contains("newt_agent.core"), "stitched public path");
374        assert!(known.contains("newt_agent"), "umbrella root");
375        // a genuinely-absent module is still NOT known
376        assert!(!known.contains("newt_agent._newt_core"));
377        assert!(!known.contains("newt_core"));
378    }
379
380    #[test]
381    fn public_stitched_path_drops_private_segments() {
382        assert_eq!(
383            public_stitched_path("newt_agent._newt_agent.core"),
384            "newt_agent.core"
385        );
386        // no private segment → unchanged
387        assert_eq!(public_stitched_path("a.b.c"), "a.b.c");
388    }
389
390    #[test]
391    fn dotted_ancestors_are_every_prefix() {
392        assert_eq!(
393            dotted_ancestors("a.b.c"),
394            vec!["a".to_string(), "a.b".to_string(), "a.b.c".to_string()]
395        );
396        assert_eq!(dotted_ancestors("solo"), vec!["solo".to_string()]);
397    }
398}