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}