Skip to main content

memstead_base/
pipeline.rs

1//! Pipeline primitives — **Medium · Facet · Projection · Ingest**.
2//!
3//! The four-primitive model that replaces the conflated Scope / Projection /
4//! Ingest shape. The conceptual boundaries between the four
5//! primitives are normative; this module is the engine-side
6//! data shape that the workspace store persists and the pipeline loader
7//! exposes.
8//!
9//! - [`Medium`] — *territory*: a passive, named, typed reference to a body of
10//!   information (no selection logic, no engagement metadata, no preparation).
11//! - [`Facet`] — *engagement*: how a projection reads/writes a medium —
12//!   a selection (allow/deny patterns), an engagement contract, and an
13//!   optional deterministic preparation step.
14//! - [`Projection`] — *obligation*: maps source facets (+ optional reference
15//!   mems) to a destination mem. The one place agent reasoning lives.
16//! - [`Ingest`] — *schedule*: runs a projection in a mode/trigger/batch.
17//!
18//! These are operator-edited configs. The loader's job is load + validate +
19//! expose read-only; nothing here fetches, transforms, or schedules.
20
21use serde::{Deserialize, Serialize};
22
23/// What kind of surface a [`Medium`] references. The string forms match the
24/// `type` field of the legacy `scopes/<mem>/<name>.json` records, so
25/// the migration shim maps them without translation. `pdf` (and other
26/// non-text mediums) join this enum with their follow-up plans.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum MediumType {
30    /// A source tree of code.
31    Codebase,
32    /// A directory of files (non-code).
33    Filesystem,
34    /// Another mem's graph (reachable as the reserved id `graph` for "home").
35    Graph,
36    /// A git history.
37    Git,
38    /// Web sources.
39    Web,
40}
41
42/// A **Medium** — a passive, named, typed reference to a body of information
43/// the mem acknowledges as part of its territory. Nothing more: no
44/// selection, no engagement metadata, no preparation step.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Medium {
47    /// Stable name — facets and projections reference a medium by this.
48    pub name: String,
49    /// What kind of surface this is.
50    #[serde(rename = "type")]
51    pub medium_type: MediumType,
52    /// Where the body of information lives — a path, URL, or mem id,
53    /// interpreted per [`Self::medium_type`]. Opaque to this layer.
54    pub pointer: String,
55}
56
57/// Whether a [`PatternEntry`] admits or excludes the matched paths.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum PatternMode {
61    /// Paths matching this pattern are in reach.
62    Allow,
63    /// Paths matching this pattern are excluded.
64    Deny,
65}
66
67/// One allow/deny glob in a [`Facet`]'s selection over its medium. Mirrors the
68/// `{ path, mode }` entries of the legacy scope `tree`.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct PatternEntry {
71    /// Glob pattern, interpreted relative to the referenced medium's pointer.
72    pub path: String,
73    /// Whether the pattern admits or excludes.
74    pub mode: PatternMode,
75}
76
77/// A **Facet** — a named way a projection engages with a [`Medium`]: the
78/// subset in reach, the engagement contract, and an optional preparation step.
79///
80/// The facet record is deliberately heterogeneous (a *source* facet typically
81/// carries `scope` + `preparation`; a *destination* facet carries engagement
82/// discipline) — forcing a uniform shape would smuggle complexity elsewhere.
83/// The single-type-with-optional-fields modelling is chosen for machinery
84/// simplicity (concept-doc Open Question 2); the `engagement` contract stays a
85/// free-form JSON value because its shape is medium-type- and side-specific
86/// (verbs, tools, terminology, discipline) and is not load-bearing for the
87/// loader's structural validation.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct Facet {
90    /// Stable name — projections reference a facet by this.
91    pub name: String,
92    /// The [`Medium`] (by name) this facet is a perspective on. A facet
93    /// always references exactly one medium.
94    pub medium: String,
95    /// Allow/deny selection over the referenced medium. Empty = whole medium.
96    #[serde(default)]
97    pub scope: Vec<PatternEntry>,
98    /// Engagement contract — verbs, tools, terminology, discipline. Free-form
99    /// because the shape differs by medium type and by source/destination
100    /// side; the engine does not interpret it.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub engagement: Option<serde_json::Value>,
103    /// Optional deterministic preparation step (string identifier, e.g.
104    /// `pdf-to-markdown`). Unset for every text medium today. A facet that
105    /// names a preparation the engine has no implementation for is accepted at
106    /// rest but reported unsupported at run time — no silent skip, no crash.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub preparation: Option<String>,
109}
110
111/// A **Projection** — the obligation that connects source facets (and optional
112/// read-only reference mems) to a single destination mem. The only place
113/// agent reasoning lives; it carries no scope, preparation, or medium metadata
114/// of its own (all of that lives in the facets it references).
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct Projection {
117    /// What the projection is trying to accomplish — prose for the agent.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub intent: Option<String>,
120    /// Source facets (by name) the projection consumes.
121    #[serde(default)]
122    pub source_facets: Vec<String>,
123    /// Read-only reference mems that supply cross-mem context.
124    #[serde(default)]
125    pub reference_mems: Vec<String>,
126    /// The mem this projection writes into.
127    pub destination_mem: String,
128}
129
130/// How an [`Ingest`] run engages its projection.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "kebab-case")]
133pub enum IngestMode {
134    /// Build out new coverage.
135    Discovery,
136    /// Improve existing coverage.
137    Refinement,
138    /// A single bounded pass.
139    OneShot,
140}
141
142/// What sets an [`Ingest`] running.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "kebab-case")]
145pub enum IngestTrigger {
146    /// Repeated runs (the ingest skill loops it).
147    Loop,
148    /// Operator-initiated.
149    Manual,
150    /// Fired by an external event.
151    OnEvent,
152}
153
154/// An **Ingest** — a runnable schedule that runs a [`Projection`] in a given
155/// mode, on a trigger, in batches, with optional per-run deny-path overrides.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct Ingest {
158    /// The projection (by name) this ingest runs.
159    pub projection: String,
160    /// Discovery / refinement / one-shot.
161    pub mode: IngestMode,
162    /// Loop / manual / on-event.
163    pub trigger: IngestTrigger,
164    /// How many artifacts a single run processes.
165    pub batch_size: u32,
166    /// Paths excluded for this ingest's runs, on top of facet scope.
167    #[serde(default)]
168    pub deny_paths: Vec<String>,
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    /// A medium round-trips and its `type` serialises to the lowercase form
176    /// the legacy scope JSON used.
177    #[test]
178    fn medium_round_trips_with_lowercase_type() {
179        let m = Medium {
180            name: "source-tree".to_string(),
181            medium_type: MediumType::Codebase,
182            pointer: "../macos".to_string(),
183        };
184        let json = serde_json::to_string(&m).unwrap();
185        assert!(json.contains(r#""type":"codebase""#), "got {json}");
186        let back: Medium = serde_json::from_str(&json).unwrap();
187        assert_eq!(back, m);
188    }
189
190    /// A source facet with allow/deny scope and no preparation round-trips,
191    /// and the unset `preparation`/`engagement` keys are omitted on the wire.
192    #[test]
193    fn facet_round_trips_and_omits_unset_optional_fields() {
194        let f = Facet {
195            name: "source-files".to_string(),
196            medium: "source-tree".to_string(),
197            scope: vec![
198                PatternEntry {
199                    path: "../macos/**/*.swift".to_string(),
200                    mode: PatternMode::Allow,
201                },
202                PatternEntry {
203                    path: "../macos/specs/**".to_string(),
204                    mode: PatternMode::Deny,
205                },
206            ],
207            engagement: None,
208            preparation: None,
209        };
210        let json = serde_json::to_string(&f).unwrap();
211        assert!(
212            !json.contains("preparation"),
213            "unset preparation omitted: {json}"
214        );
215        assert!(
216            !json.contains("engagement"),
217            "unset engagement omitted: {json}"
218        );
219        assert!(json.contains(r#""mode":"deny""#), "got {json}");
220        let back: Facet = serde_json::from_str(&json).unwrap();
221        assert_eq!(back, f);
222        assert_eq!(back.preparation, None);
223    }
224
225    /// A facet declaring a preparation identifier round-trips with the value
226    /// present — the slot is reserved even though no implementation exists.
227    #[test]
228    fn facet_preparation_slot_round_trips_when_set() {
229        let f = Facet {
230            name: "manual-pages".to_string(),
231            medium: "manuals".to_string(),
232            scope: Vec::new(),
233            engagement: Some(serde_json::json!({ "readVerb": "Read PDF" })),
234            preparation: Some("pdf-to-markdown".to_string()),
235        };
236        let json = serde_json::to_string(&f).unwrap();
237        let back: Facet = serde_json::from_str(&json).unwrap();
238        assert_eq!(back.preparation.as_deref(), Some("pdf-to-markdown"));
239        assert_eq!(back, f);
240    }
241
242    /// A projection maps source facets + reference mems to one destination.
243    #[test]
244    fn projection_round_trips() {
245        let p = Projection {
246            intent: Some("Swift macOS app source.".to_string()),
247            source_facets: vec!["source-files".to_string()],
248            reference_mems: vec!["engine".to_string()],
249            destination_mem: "macos".to_string(),
250        };
251        let json = serde_json::to_string(&p).unwrap();
252        let back: Projection = serde_json::from_str(&json).unwrap();
253        assert_eq!(back, p);
254        assert_eq!(back.destination_mem, "macos");
255    }
256
257    /// An ingest round-trips; `mode`/`trigger` use the kebab-case wire forms
258    /// (`one-shot`, `on-event`) the enum renames declare.
259    #[test]
260    fn ingest_round_trips_with_kebab_mode_and_trigger() {
261        let i = Ingest {
262            projection: "macos/graph".to_string(),
263            mode: IngestMode::Discovery,
264            trigger: IngestTrigger::Loop,
265            batch_size: 20,
266            deny_paths: vec!["VISION.md".to_string(), "dev".to_string()],
267        };
268        let json = serde_json::to_string(&i).unwrap();
269        assert!(json.contains(r#""mode":"discovery""#), "got {json}");
270        assert!(json.contains(r#""trigger":"loop""#), "got {json}");
271        let back: Ingest = serde_json::from_str(&json).unwrap();
272        assert_eq!(back, i);
273
274        // The kebab-case variants serialise as the doc names.
275        let one_shot = serde_json::to_string(&IngestMode::OneShot).unwrap();
276        assert_eq!(one_shot, r#""one-shot""#);
277        let on_event = serde_json::to_string(&IngestTrigger::OnEvent).unwrap();
278        assert_eq!(on_event, r#""on-event""#);
279    }
280}