1use std::io::Read;
47use std::path::Path;
48
49use anyhow::{anyhow, bail, Context, Result};
50use scema_world::WorldState;
51
52use crate::observer::Observer;
53
54pub const MAX_IMPORT_BYTES: u64 = 16 * 1024 * 1024;
60
61#[derive(Clone, Copy, Debug, Default)]
63pub struct ImportObserver;
64
65impl ImportObserver {
66 pub fn new() -> Self {
67 ImportObserver
68 }
69
70 pub fn from_json(text: &str, source: &str) -> Result<WorldState> {
76 let mut world: WorldState = serde_json::from_str(text).with_context(|| {
77 format!("{source} is not a scema-world WorldState (see scema-world's JSON shape)")
78 })?;
79 check(&world).with_context(|| format!("{source} parsed but is not internally consistent"))?;
80 world.observer = stamp(&world.observer);
81 Ok(world)
82 }
83
84 pub fn from_stdin() -> Result<WorldState> {
86 let mut text = String::new();
87 std::io::stdin()
88 .take(MAX_IMPORT_BYTES)
89 .read_to_string(&mut text)
90 .context("reading a world from stdin")?;
91 if text.trim().is_empty() {
92 bail!(
93 "nothing arrived on stdin. A producer that printed its help text or failed \
94 silently looks exactly like this — check its exit code."
95 );
96 }
97 ImportObserver::from_json(&text, "stdin")
98 }
99
100 pub fn from_file(path: &Path) -> Result<WorldState> {
102 let meta = std::fs::metadata(path)
103 .with_context(|| format!("reading {}", path.display()))?;
104 if meta.len() > MAX_IMPORT_BYTES {
105 bail!(
106 "{} is {} bytes, over the {MAX_IMPORT_BYTES}-byte import cap. A world is a \
107 description of an environment, not a dump of it.",
108 path.display(),
109 meta.len()
110 );
111 }
112 let text = std::fs::read_to_string(path)
113 .with_context(|| format!("reading {}", path.display()))?;
114 ImportObserver::from_json(&text, &path.display().to_string())
115 }
116}
117
118fn stamp(observer: &str) -> String {
124 let name = observer.trim();
125 if name.is_empty() {
126 return "imported:unknown".to_string();
130 }
131 if name.starts_with("imported:") {
132 return name.to_string();
133 }
134 format!("imported:{name}")
135}
136
137fn check(w: &WorldState) -> Result<()> {
142 let mut ids: Vec<&str> = w.signals.iter().map(|s| s.id.as_str()).collect();
145 ids.sort_unstable();
146 let before = ids.len();
147 ids.dedup();
148 if before != ids.len() {
149 bail!("two signals share an id; `--ground` could not name either unambiguously");
150 }
151
152 let mut object_ids: Vec<&str> = w.objects.iter().map(|o| o.id.as_str()).collect();
153 object_ids.sort_unstable();
154 let before = object_ids.len();
155 object_ids.dedup();
156 if before != object_ids.len() {
157 bail!("two objects share an id");
158 }
159
160 for s in &w.signals {
161 if s.id.trim().is_empty() {
162 bail!("a signal has an empty id");
163 }
164 if !s.magnitude.is_finite() || s.magnitude < 0.0 || s.magnitude > 1.0 {
167 bail!(
168 "signal `{}` has magnitude {}, outside [0,1] — clamp it in the producer",
169 s.id,
170 s.magnitude
171 );
172 }
173 if s.measured && s.evidence.is_empty() {
177 bail!(
178 "signal `{}` claims to be measured but cites no evidence; either cite the \
179 count or set measured=false",
180 s.id
181 );
182 }
183 }
184
185 for f in &w.facts {
186 if !f.confidence.is_finite() || f.confidence < 0.0 || f.confidence > 1.0 {
187 bail!("fact `{} {} {}` has confidence outside [0,1]", f.subject, f.predicate, f.object);
188 }
189 }
190
191 if let Some(total) = w.extent.total {
192 if w.extent.observed > total {
193 bail!(
194 "extent claims {} observed of {} total; if the denominator is unknown it must \
195 be null, not smaller than the numerator",
196 w.extent.observed,
197 total
198 );
199 }
200 }
201
202 if w.entity.locator.trim().is_empty() {
203 bail!("the entity has no locator; it is what a decision record cites to find this again");
204 }
205
206 Ok(())
207}
208
209impl Observer for ImportObserver {
210 fn name(&self) -> &str {
211 "import"
212 }
213
214 fn about(&self) -> &str {
215 "a WorldState produced elsewhere: `-` for stdin, or a path to a .json file"
216 }
217
218 fn handles(&self, locator: &str) -> bool {
219 let l = locator.trim();
220 l == "-" || l.eq_ignore_ascii_case("stdin") || l.to_ascii_lowercase().ends_with(".json")
221 }
222
223 fn observe(&self, locator: &str) -> Result<WorldState> {
224 let l = locator.trim();
225 if l == "-" || l.eq_ignore_ascii_case("stdin") {
226 return ImportObserver::from_stdin();
227 }
228 if !self.handles(l) {
229 return Err(anyhow!(
230 "`{l}` is not something this observer handles; it takes `-` or a path ending .json"
231 ));
232 }
233 ImportObserver::from_file(Path::new(l))
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use scema_world::{Domain, Entity, EntityKind, Extent, Polarity, Provenance, Signal};
241 use std::fs;
242
243 fn minimal() -> serde_json::Value {
244 serde_json::json!({
245 "observer": "mesh",
246 "entity": { "kind": "service", "locator": "/bot", "label": "bot" },
247 "domain": "trading",
248 "observed_at": 1_700_000_000i64,
249 "objects": [],
250 "facts": [],
251 "signals": [],
252 "extent": { "observed": 3, "total": 3, "note": "collected" },
253 "blind_spots": []
254 })
255 }
256
257 fn with_signals(signals: serde_json::Value) -> String {
258 let mut v = minimal();
259 v["signals"] = signals;
260 v.to_string()
261 }
262
263 #[test]
264 fn an_imported_world_can_never_claim_it_was_observed_here() {
265 let w = ImportObserver::from_json(&minimal().to_string(), "t").unwrap();
268 assert_eq!(w.observer, "imported:mesh");
269 }
270
271 #[test]
272 fn importing_twice_does_not_stack_prefixes() {
273 let mut v = minimal();
276 v["observer"] = serde_json::json!("imported:mesh");
277 let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
278 assert_eq!(w.observer, "imported:mesh");
279 }
280
281 #[test]
282 fn a_world_with_no_observer_name_is_attributed_to_nobody_rather_than_to_us() {
283 let mut v = minimal();
284 v["observer"] = serde_json::json!(" ");
285 let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
286 assert_eq!(w.observer, "imported:unknown");
287 }
288
289 #[test]
290 fn a_counted_signal_that_cites_nothing_is_refused() {
291 let text = with_signals(serde_json::json!([{
296 "id": "a", "polarity": "risk", "label": "x", "detail": "",
297 "magnitude": 0.5, "measured": true, "targets": [], "evidence": []
298 }]));
299 let err = ImportObserver::from_json(&text, "t").unwrap_err().to_string();
300 let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
301 assert!(chain.contains("cites no evidence"), "{err} / {chain}");
302 }
303
304 #[test]
305 fn an_estimated_signal_may_cite_nothing() {
306 let text = with_signals(serde_json::json!([{
309 "id": "a", "polarity": "risk", "label": "x", "detail": "",
310 "magnitude": 0.5, "measured": false, "targets": [], "evidence": []
311 }]));
312 assert!(ImportObserver::from_json(&text, "t").is_ok());
313 }
314
315 #[test]
316 fn a_magnitude_outside_the_unit_interval_is_refused_with_the_signal_named() {
317 for bad in [1.5, -0.2] {
320 let text = with_signals(serde_json::json!([{
321 "id": "loud", "polarity": "risk", "label": "x", "detail": "",
322 "magnitude": bad, "measured": true, "targets": [], "evidence": ["counted"]
323 }]));
324 let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
325 assert!(chain.contains("loud"), "{chain}");
326 assert!(chain.contains("outside [0,1]"), "{chain}");
327 }
328 }
329
330 #[test]
331 fn duplicate_signal_ids_are_refused_because_ground_could_not_name_one() {
332 let sig = |id: &str| {
333 serde_json::json!({
334 "id": id, "polarity": "risk", "label": "x", "detail": "",
335 "magnitude": 0.5, "measured": true, "targets": [], "evidence": ["counted"]
336 })
337 };
338 let text = with_signals(serde_json::json!([sig("a"), sig("a")]));
339 let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
340 assert!(chain.contains("share an id"), "{chain}");
341 }
342
343 #[test]
344 fn an_extent_whose_numerator_exceeds_its_denominator_is_refused() {
345 let mut v = minimal();
349 v["extent"] = serde_json::json!({ "observed": 9, "total": 3, "note": "?" });
350 let chain = format!("{:#}", ImportObserver::from_json(&v.to_string(), "t").unwrap_err());
351 assert!(chain.contains("not smaller than the numerator"), "{chain}");
352 }
353
354 #[test]
355 fn an_unknown_denominator_is_accepted_and_is_the_correct_way_to_say_so() {
356 let mut v = minimal();
357 v["extent"] = serde_json::json!({ "observed": 9, "total": null, "note": "capped" });
358 let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
359 assert_eq!(w.extent.fraction(), None);
360 }
361
362 #[test]
363 fn an_entity_with_no_locator_is_refused() {
364 let mut v = minimal();
367 v["entity"]["locator"] = serde_json::json!("");
368 assert!(ImportObserver::from_json(&v.to_string(), "t").is_err());
369 }
370
371 #[test]
372 fn the_locator_grammar_is_narrow_so_repo_observer_still_wins_a_directory() {
373 let o = ImportObserver;
377 assert!(o.handles("-"));
378 assert!(o.handles("stdin"));
379 assert!(o.handles("mesh.json"));
380 assert!(o.handles("/tmp/World.JSON"));
381 assert!(!o.handles("."));
382 assert!(!o.handles("/some/project"));
383 assert!(!o.handles("crates/scema-tools"));
384 }
385
386 #[test]
387 fn a_file_that_is_not_json_says_what_it_should_have_been() {
388 let dir = std::env::temp_dir().join(format!("scema-import-{}", std::process::id()));
389 fs::create_dir_all(&dir).unwrap();
390 let path = dir.join("bad.json");
391 fs::write(&path, "not json").unwrap();
392 let chain = format!("{:#}", ImportObserver.observe(path.to_str().unwrap()).unwrap_err());
393 assert!(chain.contains("WorldState"), "{chain}");
394 fs::remove_dir_all(&dir).ok();
395 }
396
397 #[test]
398 fn a_real_world_round_trips_through_the_importer_unchanged_but_for_the_stamp() {
399 let original = WorldState {
402 observer: "mesh".into(),
403 entity: Entity {
404 kind: EntityKind::Service,
405 locator: "/bot".into(),
406 label: "sniper".into(),
407 },
408 domain: Domain::Trading,
409 observed_at: 1_700_000_000,
410 objects: vec![],
411 facts: vec![],
412 signals: vec![Signal {
413 id: "veto:dqstar".into(),
414 polarity: Polarity::Risk,
415 label: "DQ* is suppressing buys".into(),
416 detail: String::new(),
417 magnitude: 0.8,
418 measured: true,
419 targets: vec!["learner.dqstar".into()],
420 evidence: vec!["counted 12 consecutive vetoes".into()],
421 }],
422 extent: Extent::complete(7, "collected"),
423 blind_spots: vec!["scematica-metrics.json: absent".into()],
424 };
425 let text = serde_json::to_string(&original).unwrap();
426 let back = ImportObserver::from_json(&text, "t").unwrap();
427
428 assert_eq!(back.observer, "imported:mesh");
429 assert_eq!(back.entity, original.entity);
430 assert_eq!(back.signals, original.signals);
431 assert_eq!(back.blind_spots, original.blind_spots);
432 assert_eq!(back.extent, original.extent);
433 }
434
435 fn fixture(name: &str) -> String {
447 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
448 .join("fixtures")
449 .join(name);
450 std::fs::read_to_string(&path)
451 .unwrap_or_else(|e| panic!("reading {}: {e}", path.display()))
452 }
453
454 #[test]
456 fn every_producer_fixture_imports() {
457 for (file, observer) in [
458 ("mesh-world.json", "imported:mesh"),
459 ("alchem-world.json", "imported:alchem-link"),
460 ("page-world.json", "imported:page"),
461 ] {
462 let w = ImportObserver::from_json(&fixture(file), file)
463 .unwrap_or_else(|e| panic!("{file}: {e:#}"));
464 assert_eq!(w.observer, observer, "{file}");
465 assert!(!w.entity.locator.trim().is_empty(), "{file}");
466 }
467 }
468
469 #[test]
474 fn every_producer_reports_what_it_could_not_see() {
475 for file in ["mesh-world.json", "alchem-world.json", "page-world.json"] {
476 let w = ImportObserver::from_json(&fixture(file), file).unwrap();
477 assert!(
478 !w.blind_spots.is_empty(),
479 "{file} reports perfect visibility, which no real observation has"
480 );
481 }
482 }
483
484 #[test]
491 fn no_producer_claims_a_measurement_it_cannot_cite() {
492 for file in ["mesh-world.json", "alchem-world.json", "page-world.json"] {
493 let w = ImportObserver::from_json(&fixture(file), file).unwrap();
494 for s in &w.signals {
495 if s.measured {
496 assert!(!s.evidence.is_empty(), "{file}: `{}` cites nothing", s.id);
497 }
498 assert!((0.0..=1.0).contains(&s.magnitude), "{file}: `{}`", s.id);
499 }
500 }
501 }
502
503 #[test]
511 fn stale_and_absent_survive_the_wire() {
512 let mesh = ImportObserver::from_json(&fixture("mesh-world.json"), "mesh").unwrap();
513 assert!(
514 mesh.objects.iter().any(|o| matches!(o.provenance, Provenance::Stale { .. })),
515 "the mesh fixture should carry at least one stale unit"
516 );
517 assert!(
518 mesh.objects.iter().any(|o| matches!(o.provenance, Provenance::Absent)),
519 "the mesh fixture should carry at least one unseen unit"
520 );
521
522 let feeds = ImportObserver::from_json(&fixture("alchem-world.json"), "alchem").unwrap();
523 assert!(
524 feeds.objects.iter().any(|o| matches!(o.provenance, Provenance::Stale { .. })),
525 "the oracle fixture should carry a feed past its own heartbeat"
526 );
527 for o in feeds.objects.iter().filter(|o| o.provenance == Provenance::Absent) {
530 assert!(o.attrs.is_empty(), "an unread feed must carry no values: {}", o.id);
531 }
532 }
533
534 #[test]
541 fn a_perceived_page_carries_no_query_string() {
542 let w = ImportObserver::from_json(&fixture("page-world.json"), "page").unwrap();
543 assert!(!w.entity.locator.contains('?'), "{}", w.entity.locator);
544 assert!(!w.entity.locator.contains("SECRET"), "{}", w.entity.locator);
545 }
546
547 #[test]
554 fn the_domain_lets_a_specialist_decline_correctly() {
555 let mesh = ImportObserver::from_json(&fixture("mesh-world.json"), "mesh").unwrap();
556 assert_eq!(mesh.domain, scema_world::Domain::Trading);
557
558 for file in ["alchem-world.json", "page-world.json"] {
559 let w = ImportObserver::from_json(&fixture(file), file).unwrap();
560 assert_eq!(w.domain, scema_world::Domain::Unknown, "{file}");
561 }
562 }
563
564}