Skip to main content

scema_tools/
import.rs

1//! [`ImportObserver`]: a world perceived somewhere else.
2//!
3//! The observer that makes omni's domain-agnosticism *operational* rather than merely
4//! stated. `RepoObserver` can perceive a source tree because it is written in Rust and runs
5//! in this process. It cannot perceive a running Solana bot, a set of Chainlink oracle
6//! feeds, or a DOM — those live behind a different lockfile, a Python package, and a browser
7//! respectively, and pulling any of them in here would make omni a hub of domain
8//! dependencies, which is the exact thing the workspace note forbids.
9//!
10//! The alternative that works is the one the browser extension already proved:
11//!
12//! > **The thing being observed describes itself in `scema-world`'s vocabulary, and omni
13//! > reads that.**
14//!
15//! `plugins/scema-web/src/perceive.js` emits `WorldState` JSON in 300 lines of dependency-
16//! free JavaScript and nothing above perception needed a line changed to gain a browser.
17//! There are now four producers on that contract — `RepoObserver` here, `perceive.js` in the
18//! extension, `scematica_mesh::omni` in the bot workspace, and `alchem_link.omni` in
19//! Python — and only the first of them is written in a language this crate can link.
20//!
21//! ```console
22//! $ mesh-dashboard --world | scema simulate "keep the pipeline honest" --path -
23//! $ alchem-link omni -n base > feeds.json && scema observe feeds.json
24//! ```
25//!
26//! ## The observer field is always rewritten, and that is the load-bearing part
27//!
28//! An imported world is **not** trusted to describe its own provenance — but it does not
29//! have to be. Whatever the producer called itself, [`ImportObserver`] prefixes it with
30//! `imported:`, exactly as `scema-daemon` prefixes a wire-supplied world with `client:`. A
31//! decision record can therefore never claim that a world which arrived as a file was
32//! observed locally, and a reader of that record can see in one field which it was.
33//!
34//! ## What it validates, and what it deliberately does not
35//!
36//! It validates the *shape*: the JSON has to deserialise into a `WorldState`, and a few
37//! internal-consistency rules are checked because violating them makes downstream output
38//! wrong rather than merely odd (a signal id used twice cannot be named unambiguously by
39//! `--ground`; a magnitude outside `[0,1]` would dominate a ranking by arithmetic).
40//!
41//! It does **not** validate the *claims*. A producer that reports a stale feed as `Live`, or
42//! counts a signal it did not count, is lying, and no amount of parsing catches that. The
43//! honest response is not a deeper check — it is the `imported:` prefix, which tells a
44//! reader exactly whose word this is.
45
46use std::io::Read;
47use std::path::Path;
48
49use anyhow::{anyhow, bail, Context, Result};
50use scema_world::WorldState;
51
52use crate::observer::Observer;
53
54/// Bytes read before an import gives up.
55///
56/// A world is a description of an environment, not a dump of it — the repo observer's
57/// output for this workspace is about 60 KB. Sixteen megabytes is roomy enough for anything
58/// legitimate and small enough that a producer stuck in a loop cannot exhaust memory here.
59pub const MAX_IMPORT_BYTES: u64 = 16 * 1024 * 1024;
60
61/// Reads a `WorldState` that something else produced.
62#[derive(Clone, Copy, Debug, Default)]
63pub struct ImportObserver;
64
65impl ImportObserver {
66    pub fn new() -> Self {
67        ImportObserver
68    }
69
70    /// Parse, check, and stamp a world from raw JSON.
71    ///
72    /// Split out from [`Observer::observe`] so the daemon and the MCP server can reuse the
73    /// same validation on a world that arrived over the wire rather than from a file. One
74    /// implementation of "is this a usable world", not three.
75    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    /// Read from standard input.
85    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    /// Read from a file.
101    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
118/// Prefix a producer's own observer name so a record cannot claim a local observation.
119///
120/// Idempotent: importing an already-imported world does not stack prefixes, because a
121/// pipeline that passed a world through twice would otherwise produce
122/// `imported:imported:page` and make the origin harder to read rather than easier.
123fn stamp(observer: &str) -> String {
124    let name = observer.trim();
125    if name.is_empty() {
126        // A producer that named no observer is a producer whose output cannot be attributed.
127        // `unknown` is the honest label; silently inheriting `import` would credit this crate
128        // with an observation it did not make.
129        return "imported:unknown".to_string();
130    }
131    if name.starts_with("imported:") {
132        return name.to_string();
133    }
134    format!("imported:{name}")
135}
136
137/// Refuse a world that does not conform, listing every reason at once.
138///
139/// The rules live in [`crate::conform`] rather than here, because `scema check` has to
140/// report exactly what the importer enforces. Two implementations would drift, and the
141/// failure mode is the one that destroys trust in tooling fastest: a producer that passes
142/// the checker and is then refused by the importer, or the reverse.
143///
144/// Every failure is listed, not just the first. A producer author with four problems should
145/// learn about four problems.
146fn check(w: &WorldState) -> Result<()> {
147    use crate::conform::{conform, has_failure, Level};
148    let findings = conform(w);
149    if !has_failure(&findings) {
150        return Ok(());
151    }
152    let mut msg = String::new();
153    for f in findings.iter().filter(|f| f.level == Level::Fail) {
154        msg.push_str("
155  - ");
156        msg.push_str(&f.message);
157        if let Some(fix) = &f.fix {
158            msg.push_str("
159    fix: ");
160            msg.push_str(fix);
161        }
162    }
163    msg.push_str("
164
165  `scema check <file>` prints the full report.");
166    bail!("{msg}");
167}
168
169impl Observer for ImportObserver {
170    fn name(&self) -> &str {
171        "import"
172    }
173
174    fn about(&self) -> &str {
175        "a WorldState produced elsewhere: `-` for stdin, or a path to a .json file"
176    }
177
178    fn handles(&self, locator: &str) -> bool {
179        let l = locator.trim();
180        l == "-" || l.eq_ignore_ascii_case("stdin") || l.to_ascii_lowercase().ends_with(".json")
181    }
182
183    fn observe(&self, locator: &str) -> Result<WorldState> {
184        let l = locator.trim();
185        if l == "-" || l.eq_ignore_ascii_case("stdin") {
186            return ImportObserver::from_stdin();
187        }
188        if !self.handles(l) {
189            return Err(anyhow!(
190                "`{l}` is not something this observer handles; it takes `-` or a path ending .json"
191            ));
192        }
193        ImportObserver::from_file(Path::new(l))
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use scema_world::{Domain, Entity, EntityKind, Extent, Polarity, Provenance, Signal};
201    use std::fs;
202
203    fn minimal() -> serde_json::Value {
204        serde_json::json!({
205            "schema": scema_world::WORLD_SCHEMA,
206            "observer": "mesh",
207            "entity": { "kind": "service", "locator": "/bot", "label": "bot" },
208            "domain": "trading",
209            "observed_at": 1_700_000_000i64,
210            "objects": [],
211            "facts": [],
212            "signals": [],
213            "extent": { "observed": 3, "total": 3, "note": "collected" },
214            "blind_spots": []
215        })
216    }
217
218    fn with_signals(signals: serde_json::Value) -> String {
219        let mut v = minimal();
220        v["signals"] = signals;
221        v.to_string()
222    }
223
224    #[test]
225    fn an_imported_world_can_never_claim_it_was_observed_here() {
226        // The whole point of the crate. `scema-daemon` makes the same rewrite for a world
227        // that arrived over the wire, for the same reason.
228        let w = ImportObserver::from_json(&minimal().to_string(), "t").unwrap();
229        assert_eq!(w.observer, "imported:mesh");
230    }
231
232    #[test]
233    fn importing_twice_does_not_stack_prefixes() {
234        // A pipeline that passed a world through two stages would otherwise produce
235        // `imported:imported:mesh`, which makes the origin harder to read, not easier.
236        let mut v = minimal();
237        v["observer"] = serde_json::json!("imported:mesh");
238        let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
239        assert_eq!(w.observer, "imported:mesh");
240    }
241
242    #[test]
243    fn a_world_with_no_observer_name_is_attributed_to_nobody_rather_than_to_us() {
244        let mut v = minimal();
245        v["observer"] = serde_json::json!("   ");
246        let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
247        assert_eq!(w.observer, "imported:unknown");
248    }
249
250    #[test]
251    fn a_counted_signal_that_cites_nothing_is_refused() {
252        // The laundering this validation exists for. `measured: true` is a claim that
253        // somebody counted something, and it is the claim `scema-sim` relies on to score a
254        // real expected gain. A producer making it with nothing to cite is producing a
255        // hallucination with a decimal point on it.
256        let text = with_signals(serde_json::json!([{
257            "id": "a", "polarity": "risk", "label": "x", "detail": "",
258            "magnitude": 0.5, "measured": true, "targets": [], "evidence": []
259        }]));
260        let err = ImportObserver::from_json(&text, "t").unwrap_err().to_string();
261        let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
262        assert!(chain.contains("cites no evidence"), "{err} / {chain}");
263    }
264
265    #[test]
266    fn an_estimated_signal_may_cite_nothing() {
267        // The other half: a producer that admits it guessed is behaving correctly, and
268        // `measured: false` is exactly how it says so.
269        let text = with_signals(serde_json::json!([{
270            "id": "a", "polarity": "risk", "label": "x", "detail": "",
271            "magnitude": 0.5, "measured": false, "targets": [], "evidence": []
272        }]));
273        assert!(ImportObserver::from_json(&text, "t").is_ok());
274    }
275
276    #[test]
277    fn a_magnitude_outside_the_unit_interval_is_refused_with_the_signal_named() {
278        // It would dominate a ranking by arithmetic rather than by importance, and the
279        // producer is the only place that can be fixed.
280        for bad in [1.5, -0.2] {
281            let text = with_signals(serde_json::json!([{
282                "id": "loud", "polarity": "risk", "label": "x", "detail": "",
283                "magnitude": bad, "measured": true, "targets": [], "evidence": ["counted"]
284            }]));
285            let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
286            assert!(chain.contains("loud"), "{chain}");
287            assert!(chain.contains("outside [0,1]"), "{chain}");
288        }
289    }
290
291    #[test]
292    fn duplicate_signal_ids_are_refused_because_ground_could_not_name_one() {
293        let sig = |id: &str| {
294            serde_json::json!({
295                "id": id, "polarity": "risk", "label": "x", "detail": "",
296                "magnitude": 0.5, "measured": true, "targets": [], "evidence": ["counted"]
297            })
298        };
299        let text = with_signals(serde_json::json!([sig("a"), sig("a")]));
300        let chain = format!("{:#}", ImportObserver::from_json(&text, "t").unwrap_err());
301        assert!(chain.contains("share the id"), "{chain}");
302    }
303
304    #[test]
305    fn an_extent_whose_numerator_exceeds_its_denominator_is_refused() {
306        // `Extent::fraction` would report over 100% observed, which reads as certainty about
307        // a world the producer only partly saw. The `None` denominator exists for exactly
308        // this case and must be used instead.
309        let mut v = minimal();
310        v["extent"] = serde_json::json!({ "observed": 9, "total": 3, "note": "?" });
311        let chain = format!("{:#}", ImportObserver::from_json(&v.to_string(), "t").unwrap_err());
312        assert!(chain.contains("not a smaller number"), "{chain}");
313    }
314
315    #[test]
316    fn an_unknown_denominator_is_accepted_and_is_the_correct_way_to_say_so() {
317        let mut v = minimal();
318        v["extent"] = serde_json::json!({ "observed": 9, "total": null, "note": "capped" });
319        let w = ImportObserver::from_json(&v.to_string(), "t").unwrap();
320        assert_eq!(w.extent.fraction(), None);
321    }
322
323    #[test]
324    fn an_entity_with_no_locator_is_refused() {
325        // The locator is what a decision record cites to find this environment again. A
326        // record naming an empty string is a record nobody can re-check.
327        let mut v = minimal();
328        v["entity"]["locator"] = serde_json::json!("");
329        assert!(ImportObserver::from_json(&v.to_string(), "t").is_err());
330    }
331
332    #[test]
333    fn the_locator_grammar_is_narrow_so_repo_observer_still_wins_a_directory() {
334        // `default_observers` resolves first-match, so this observer has to be *ahead* of
335        // `RepoObserver` and must therefore claim only what it really handles. Claiming a
336        // bare path would break `scema observe .`.
337        let o = ImportObserver;
338        assert!(o.handles("-"));
339        assert!(o.handles("stdin"));
340        assert!(o.handles("mesh.json"));
341        assert!(o.handles("/tmp/World.JSON"));
342        assert!(!o.handles("."));
343        assert!(!o.handles("/some/project"));
344        assert!(!o.handles("crates/scema-tools"));
345    }
346
347    #[test]
348    fn a_file_that_is_not_json_says_what_it_should_have_been() {
349        let dir = std::env::temp_dir().join(format!("scema-import-{}", std::process::id()));
350        fs::create_dir_all(&dir).unwrap();
351        let path = dir.join("bad.json");
352        fs::write(&path, "not json").unwrap();
353        let chain = format!("{:#}", ImportObserver.observe(path.to_str().unwrap()).unwrap_err());
354        assert!(chain.contains("WorldState"), "{chain}");
355        fs::remove_dir_all(&dir).ok();
356    }
357
358    #[test]
359    fn a_real_world_round_trips_through_the_importer_unchanged_but_for_the_stamp() {
360        // The contract producers are written against: everything survives except the
361        // attribution, which is the one thing that must not.
362        let original = WorldState {
363            schema: Some(scema_world::WORLD_SCHEMA.into()),
364            observer: "mesh".into(),
365            entity: Entity {
366                kind: EntityKind::Service,
367                locator: "/bot".into(),
368                label: "sniper".into(),
369            },
370            domain: Domain::Trading,
371            observed_at: 1_700_000_000,
372            objects: vec![],
373            facts: vec![],
374            signals: vec![Signal {
375                id: "veto:dqstar".into(),
376                polarity: Polarity::Risk,
377                label: "DQ* is suppressing buys".into(),
378                detail: String::new(),
379                magnitude: 0.8,
380                measured: true,
381                targets: vec!["learner.dqstar".into()],
382                evidence: vec!["counted 12 consecutive vetoes".into()],
383            }],
384            extent: Extent::complete(7, "collected"),
385            blind_spots: vec!["scematica-metrics.json: absent".into()],
386        };
387        let text = serde_json::to_string(&original).unwrap();
388        let back = ImportObserver::from_json(&text, "t").unwrap();
389
390        assert_eq!(back.observer, "imported:mesh");
391        assert_eq!(back.entity, original.entity);
392        assert_eq!(back.signals, original.signals);
393        assert_eq!(back.blind_spots, original.blind_spots);
394        assert_eq!(back.extent, original.extent);
395    }
396
397    // ── the wire contract, against what the producers actually emitted ────────
398    //
399    // Three producers emit a `WorldState` without linking `scema-world`: the bot mesh in
400    // Rust behind another lockfile, `alchem-link` in stdlib-only Python, and the browser
401    // extension in dependency-free JavaScript. Each restates this crate's validation on its
402    // own side and fails its own tests. These close the loop from the other direction.
403    //
404    // The two halves catch different things. A producer's self-check catches a bug in that
405    // producer. A fixture catches the case where both sides were changed and only one of
406    // them was right.
407
408    fn fixture(name: &str) -> String {
409        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
410            .join("fixtures")
411            .join(name);
412        std::fs::read_to_string(&path)
413            .unwrap_or_else(|e| panic!("reading {}: {e}", path.display()))
414    }
415
416    /// Every fixture parses, validates, and keeps its producer's own attribution.
417    #[test]
418    fn every_producer_fixture_imports() {
419        for (file, observer) in [
420            ("mesh-world.json", "imported:mesh"),
421            ("alchem-world.json", "imported:alchem-link"),
422            ("page-world.json", "imported:page"),
423        ] {
424            let w = ImportObserver::from_json(&fixture(file), file)
425                .unwrap_or_else(|e| panic!("{file}: {e:#}"));
426            assert_eq!(w.observer, observer, "{file}");
427            assert!(!w.entity.locator.trim().is_empty(), "{file}");
428        }
429    }
430
431    /// A world that lists what it could not read is worth more than one that does not, and
432    /// all three producers are expected to do it. A fixture with no blind spots would mean
433    /// the capture was taken against an environment with nothing hidden in it, which is not
434    /// what any of these three observe.
435    #[test]
436    fn every_producer_reports_what_it_could_not_see() {
437        for file in ["mesh-world.json", "alchem-world.json", "page-world.json"] {
438            let w = ImportObserver::from_json(&fixture(file), file).unwrap();
439            assert!(
440                !w.blind_spots.is_empty(),
441                "{file} reports perfect visibility, which no real observation has"
442            );
443        }
444    }
445
446    /// Every counted signal cites its count, in every producer.
447    ///
448    /// The property `scema-sim` depends on to score a real expected gain. This is also
449    /// enforced by `check()` above, so the assertion is redundant *today* — and it is the
450    /// redundancy that is the point: if somebody relaxes the validator, this fails and says
451    /// which producer's real output stopped being defensible.
452    #[test]
453    fn no_producer_claims_a_measurement_it_cannot_cite() {
454        for file in ["mesh-world.json", "alchem-world.json", "page-world.json"] {
455            let w = ImportObserver::from_json(&fixture(file), file).unwrap();
456            for s in &w.signals {
457                if s.measured {
458                    assert!(!s.evidence.is_empty(), "{file}: `{}` cites nothing", s.id);
459                }
460                assert!((0.0..=1.0).contains(&s.magnitude), "{file}: `{}`", s.id);
461            }
462        }
463    }
464
465    /// Provenance survives the boundary in every arm that matters.
466    ///
467    /// The mesh fixture is captured against a directory holding stale state files and
468    /// missing ones, so it carries `Stale` and `Absent`; the oracle fixture carries `Stale`
469    /// for a feed past its heartbeat. If a producer ever collapsed those into `Live`, an
470    /// agent would act on values that were true an hour ago — the single failure this whole
471    /// arrangement exists to prevent.
472    #[test]
473    fn stale_and_absent_survive_the_wire() {
474        let mesh = ImportObserver::from_json(&fixture("mesh-world.json"), "mesh").unwrap();
475        assert!(
476            mesh.objects.iter().any(|o| matches!(o.provenance, Provenance::Stale { .. })),
477            "the mesh fixture should carry at least one stale unit"
478        );
479        assert!(
480            mesh.objects.iter().any(|o| matches!(o.provenance, Provenance::Absent)),
481            "the mesh fixture should carry at least one unseen unit"
482        );
483
484        let feeds = ImportObserver::from_json(&fixture("alchem-world.json"), "alchem").unwrap();
485        assert!(
486            feeds.objects.iter().any(|o| matches!(o.provenance, Provenance::Stale { .. })),
487            "the oracle fixture should carry a feed past its own heartbeat"
488        );
489        // An absent object must carry no attributes at all. A feed that did not answer has
490        // no price, and an attribute map with a zero in it would say it reported one.
491        for o in feeds.objects.iter().filter(|o| o.provenance == Provenance::Absent) {
492            assert!(o.attrs.is_empty(), "an unread feed must carry no values: {}", o.id);
493        }
494    }
495
496    /// A world from a page does not leak the query string into the record.
497    ///
498    /// `page-world.json` is captured from a URL carrying `?sid=SECRET`. The locator is
499    /// hashed into a decision record that outlives the tab, and query strings routinely
500    /// carry session tokens. The extension's own `test/wire.test.js` pins this against a
501    /// live daemon; this pins it against the bytes that actually crossed.
502    #[test]
503    fn a_perceived_page_carries_no_query_string() {
504        let w = ImportObserver::from_json(&fixture("page-world.json"), "page").unwrap();
505        assert!(!w.entity.locator.contains('?'), "{}", w.entity.locator);
506        assert!(!w.entity.locator.contains("SECRET"), "{}", w.entity.locator);
507    }
508
509    /// Each producer describes a different kind of world, and the domain now says which.
510    ///
511    /// `Domain` exists so a specialist can decline rather than pretend. The bot mesh is a
512    /// trading world and the Deep Q* evaluator recognises it (and then declines for want of
513    /// a checkpoint, which is a different and more useful answer). An oracle set and a web
514    /// page are declined by that evaluator either way.
515    ///
516    /// What changed when the vocabulary opened is that they are no longer declined
517    /// *identically*. Both used to report `unknown`, so nothing downstream could tell a set
518    /// of Chainlink feeds from a DOM — three producers, two indistinguishable worlds. The
519    /// decline is the same; the record of what was declined is not.
520    #[test]
521    fn the_domain_lets_a_specialist_decline_correctly() {
522        let mesh = ImportObserver::from_json(&fixture("mesh-world.json"), "mesh").unwrap();
523        assert_eq!(mesh.domain, scema_world::Domain::Trading);
524
525        let feeds = ImportObserver::from_json(&fixture("alchem-world.json"), "a").unwrap();
526        assert_eq!(feeds.domain, scema_world::Domain::Data);
527
528        let page = ImportObserver::from_json(&fixture("page-world.json"), "p").unwrap();
529        assert_eq!(page.domain, scema_world::Domain::Web);
530
531        assert_ne!(feeds.domain, page.domain, "two different worlds must not read alike");
532    }
533
534    /// Every producer declares the contract version it was written against.
535    ///
536    /// The one rule that cannot be enforced by any producer's own test suite, because the
537    /// thing it protects against is a producer nobody in this repository wrote.
538    #[test]
539    fn every_producer_declares_the_contract_it_was_written_against() {
540        for file in ["mesh-world.json", "alchem-world.json", "page-world.json"] {
541            let w = ImportObserver::from_json(&fixture(file), file).unwrap();
542            assert_eq!(w.schema.as_deref(), Some(scema_world::WORLD_SCHEMA), "{file}");
543        }
544    }
545
546    /// A world with no declared contract is refused, and the message says what to add.
547    #[test]
548    fn an_undeclared_contract_is_refused_with_the_line_to_paste() {
549        let mut v: serde_json::Value =
550            serde_json::from_str(&fixture("mesh-world.json")).unwrap();
551        v.as_object_mut().unwrap().remove("schema");
552        let err = ImportObserver::from_json(&v.to_string(), "t").unwrap_err();
553        let chain = format!("{err:#}");
554        assert!(chain.contains("scema.world/1"), "{chain}");
555        assert!(chain.contains("scema check"), "{chain}");
556    }
557
558    /// Every failure at once, rather than one per fix-and-rerun.
559    #[test]
560    fn a_producer_with_several_problems_is_told_about_all_of_them() {
561        // The development-loop property. Before the checker was shared with `scema check`,
562        // the importer bailed on the first violation, so an author with four problems
563        // learned about them one at a time.
564        let mut v = minimal();
565        v.as_object_mut().unwrap().remove("schema");
566        v["entity"]["locator"] = serde_json::json!("  ");
567        v["signals"] = serde_json::json!([
568            { "id": "dup", "polarity": "risk", "label": "l", "detail": "",
569              "magnitude": 0.5, "measured": true, "targets": [], "evidence": [] },
570            { "id": "dup", "polarity": "risk", "label": "l", "detail": "",
571              "magnitude": 4.0, "measured": false, "targets": [], "evidence": ["e"] }
572        ]);
573        let chain = format!("{:#}", ImportObserver::from_json(&v.to_string(), "t").unwrap_err());
574        for expected in ["schema", "locator", "cites no evidence", "share the id", "outside [0,1]"] {
575            assert!(chain.contains(expected), "missing `{expected}` in:
576{chain}");
577        }
578    }
579
580}