diff --git a/README.md b/README.md index ff8f01e..06ec513 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ Implemented: - §8 Rust + Axum + SQLite, single serialized writer, in-process job queue - §9a content addressing (`content_id`), computed on upload +**Schema version 2** (SR-003). `jmanifest_version` moved to 2 in lockstep with +the truth file's `schema_version` — breaking changes are batched and ship +together across all three repos. It is a **flag day**: version 1 is rejected +outright rather than carried alongside, because a v1 read path would be the one +nobody exercises and so the one that rots. + Reconciled with the system spec (see `docs/requirements.md` for the detail): - **`anneal_sec` removed.** Withdrawn upstream by AR-012/AR-013 — presence now @@ -48,6 +54,14 @@ Reconciled with the system spec (see `docs/requirements.md` for the detail): carrying `anneal_sec` is now a hard `400`, not silently ignored: it was produced by a pipeline whose window semantics differ from what this server assumes. +- **Windows carry belief and route.** `scenes` are objects rather than float + pairs: `{ "start", "end", "belief", "route" }`, where belief is the posterior + that justified the claim and route is `live`/`deferred`/`pooled`. Both are + **excluded from `content_id`** — belief is a producer-side estimate that may + differ between pipeline versions for identical timings, so hashing it would + give two servers different ids for the same content. `src/content_id.rs` is + unchanged by the bump and its golden vector still passes, which is the + evidence rather than the claim. - **Audio signature: the 120 s rule now matches both producers.** An earlier draft of §3 allowed a shortened window for items under 150 s; that conflicted with `scene-actor-extraction` IR-007 and was the weaker rule, since a @@ -110,7 +124,7 @@ curl -sX POST -H 'content-type: application/json' -d '{}' \ ## Tests ```sh -cargo test # 189 tests +cargo test # 191 tests cargo deny check # advisories, licences, bans, sources scripts/traceability-gate.sh # requirement coverage ``` @@ -123,9 +137,9 @@ git submodule update --init --recursive It reports coverage against [`docs/requirements.md`](docs/requirements.md), flags orphan tags (an ID no register defines), and fails on a >100% ratio — the -signal that the computation itself is broken. Currently **23/32 (71.9%)**; the -untraced nine are UR-007 (plugin-side), UR-008 (federation) and UR-015..018 (the -pending SR-003 bump), none of which is implemented yet. +signal that the computation itself is broken. Currently **24/32 (75%)**; the +untraced remainder is UR-007 (plugin-side) and UR-008 (federation), neither of +which is implemented here yet. Unit tests per module, plus two integration suites: diff --git a/SPEC.md b/SPEC.md index df32021..258b4b6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -107,7 +107,7 @@ and a cut fingerprint; the actor timeline payload is unchanged. ```json { - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": "504172", @@ -133,7 +133,10 @@ and a cut fingerprint; the actor timeline payload is unchanged. "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884", - "scenes": [[191.6, 209.2], [438.2, 465.6]] + "scenes": [ + { "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" }, + { "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" } + ] } ] } @@ -154,8 +157,10 @@ For an episode, `identity` is: Field notes: -- `jmanifest_version` — separate from the plugin's `schema_version`; this - versions the *exchange* envelope. +- `jmanifest_version` — **currently 2.** Separate from the plugin's + `schema_version`; this versions the *exchange* envelope, and the two remain + independent by design. They coincide at 2 only because the SR-003 bump touched + both. An unknown version is rejected outright (UR-014), never guessed at. - `identity.tmdb_id` / `imdb_id` — at least one required. These are the lookup keys. - `cut.runtime_sec` — **required**, the decoded duration of the media the @@ -173,7 +178,10 @@ Field notes: - `actors[].jellyfin_id` — **must not appear.** The server rejects uploads containing it (see §6). - `movie` (absolute path) — **must not appear.** Rejected likewise. -- `actors[].scenes` — `[start_sec, end_sec]` inclusive, sorted. **A window is a +- `actors[].scenes` — objects, not float pairs. `start`/`end` in seconds, + inclusive, sorted. `belief` is the accumulated posterior that justified the + claim, in `[0, 1]`; `route` is `live`, `deferred` or `pooled` (extraction + AR-017). Both are optional and both are **excluded from `content_id`** (§9a). **A window is a claim about scene membership, not a recognition event** (UR-013, system spec SR-002): an actor who turns away or is off-camera during a reverse shot is still present. The server therefore never reinterprets, merges, splits or @@ -188,12 +196,13 @@ Field notes: server-authoritative. Contributors should not expect a name they invented to round-trip. -### Pending schema bump — SR-003 +### Schema bump — SR-003, shipped at version 2 The truth file and the Jmanifest are consumed by components that ship independently, so breaking changes are **batched into one `schema_version` -bump** coordinated across all three repos (system spec SR-003). One bump is -currently pending, and this server must accept the new shape when it lands: +bump** coordinated across all three repos (system spec SR-003). One bump has +shipped, moving `jmanifest_version` to **2** in lockstep with the truth file's +`schema_version`: | Change | Effect here | |---|---| @@ -219,8 +228,13 @@ bump rather than after it: as an attribute, exactly as `audio_signature` is (§9a). - Quantisation is unchanged: integer centiseconds, for the reasons in §9a. -Until the bump ships, this server accepts `jmanifest_version: 1` and rejects -anything else outright (UR-014) rather than guessing at an unknown shape. +**Flag day, not dual-accept.** This server accepts `jmanifest_version: 2` and +rejects everything else outright (UR-014), including version 1. All three +components are pre-release, and a v1 read path would be the one nobody +exercises — so it is the one that would rot while being carried through every +later change to the reader. The consequence is that a pipeline still emitting v1 +is incompatible until it is updated, which is stated plainly rather than papered +over with a compatibility shim nobody tests. ### Series bundles @@ -232,7 +246,7 @@ A bundle is a thin wrapper, not a new format: ```json { - "jmanifest_version": 1, + "jmanifest_version": 2, "series": { "series_tmdb_id": "1396", "series_imdb_id": "tt0903747", diff --git a/docs/requirements.md b/docs/requirements.md index 7f525a9..f238244 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -41,6 +41,10 @@ requirement and no fixture-generation step, unlike `scene-actor-extraction`. | UR-012 | Never accept, store, or serve gallery data — reference faces or embeddings | SR-005 | High | Done | | UR-013 | Windows are scene-scoped claims; never reinterpret their boundaries | SR-002 | High | Done | | UR-014 | Reject an unknown `jmanifest_version` outright, never guess | SR-003 | High | Done | +| UR-015 | Accept `extraction.extinction_sec` in place of `anneal_sec` | SR-003 | High | Done | +| UR-016 | Accept and store `extraction.gallery_scope`; rank on it (§7) | SR-003 | Medium | Done | +| UR-017 | Accept per-window belief and identification route; `scenes` are objects | SR-003 | High | Done | +| UR-018 | Exclude belief and route from `content_id`, replicating them as attributes | SR-003 | High | Done | ### Notes on status @@ -128,6 +132,10 @@ topology is the point, so this is a deliberate choice rather than an oversight. | DR-009 | T2 | Oversized body rejected as `413` | **A lying `Content-Length` does not bypass the cap**; per-route limits differ | | DR-010 | T1 | Non-UTF-8 rejected by name | UTF-16 with and without BOM; UTF-8 BOM; declared `charset=utf-16` | | DR-011 | T1 | Canonical form is stable and order-independent | Accumulated float error hashes identically; `audio_signature` and `extraction` excluded; **golden vector verified against an independent Python implementation** | +| UR-015 | T2 | `extinction_sec` accepted and range-checked | A manifest still carrying `anneal_sec` is a hard `400` naming the field | +| UR-016 | T2 | `gallery_scope` stored and served | An unrecognised scope is a closed-vocabulary `400`, not a free string | +| UR-017 | T1 + T2 | Windows carry belief and route through storage | Belief outside `[0, 1]` rejected; an invented `route` rejected | +| UR-018 | **T1** | Belief and route absent from `content_id` | Same windows at different belief hash identically; **a real timing change still does not**, so the test cannot pass vacuously | | DR-013 | T1 | Schema mismatch is `400`, not the framework's `422` | §4 names `400` for a forbidden field, and a client checking for it would mishandle `422` | Three are worth singling out, because each verifies a claim that would otherwise @@ -162,27 +170,26 @@ requirement — so nothing is orphaned by its removal. --- -## Pending — the SR-003 schema bump +## The SR-003 schema bump — shipped at version 2 -These are `Planned` rather than absent, because the bump is coordinated across -three repos and this register should show the work rather than imply the server -is finished. +`jmanifest_version` moved to **2** in lockstep with the truth file's +`schema_version`, per SR-003's requirement that breaking changes be batched and +ship together. The two fields stay independent by design; they coincide at 2 +only because this bump touched both. -| ID | Requirement | Traces to | Priority | Status | -|---|---|---|---|---| -| UR-015 | Accept `extraction.extinction_sec` in place of `anneal_sec` | SR-003 | High | Planned | -| UR-016 | Accept and store `extraction.gallery_scope`; rank on it (§7) | SR-003 | Medium | Planned | -| UR-017 | Accept per-window belief and identification route; `scenes` becomes objects | SR-003 | High | Planned | -| UR-018 | Exclude belief from `content_id`, replicating it as an attribute | SR-003 | High | Planned | +**Flag day, not dual-accept** (`jRay` JR-003): version 1 is rejected outright. +All three components are pre-release, and a v1 read path would be the one nobody +exercises, so it is the one that would rot while being dragged through every +later change to the reader. -**UR-018 is the one with a trap in it.** Belief is a producer-side estimate that -may legitimately differ between pipeline versions for identical timings, so +**UR-018 was the one with a trap in it.** Belief is a producer-side estimate +that may legitimately differ between pipeline versions for identical timings, so including it in the canonical form would give two servers different `content_id`s for the same content — the exact failure mode §9a quantises centiseconds to -avoid. It follows `audio_signature`'s precedent: replicated as an attribute, not -part of identity. - ---- +avoid, reintroduced one field along. It follows `audio_signature`'s precedent: +replicated as an attribute, never as identity. `src/content_id.rs` is unchanged +by the bump, and its golden vector still passes — which is the evidence, not the +claim. ## Notes on coverage diff --git a/docs/traceability.md b/docs/traceability.md index dc6116b..e4af3a6 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**Generated:** 2026-07-30T16:55:59+00:00 +**Generated:** 2026-07-31T07:12:27+00:00 Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this repo's CI host can execute (`T1, T2, static`). @@ -12,12 +12,12 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Metric | Value | |---|---| | Source files scanned | 26 | -| TRACES tags found | 35 | +| TRACES tags found | 37 | | EXCEPTION tags found | 0 | | Requirements defined | 32 | -| Requirements covered | 23 | -| **Coverage** | **71.9%** (23/32) | -| Coverage of CI-executable scope | 71.9% (23/32) | +| Requirements covered | 24 | +| **Coverage** | **75.0%** (24/32) | +| Coverage of CI-executable scope | 75.0% (24/32) | | Tagged but unexecuted in CI | 0 | | Orphan tags | 0 | @@ -25,7 +25,7 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Type | Covered | Tagged but unexecuted | Defined | |---|---|---|---| -| UR | 13 | 0 | 18 | +| UR | 14 | 0 | 18 | | DR | 10 | 0 | 14 | - **PR** tags present (separate taxonomy, not counted in coverage): PR-004, PR-005, PR-006 @@ -73,10 +73,10 @@ _None._ | UR-012 | Done | T2 | SR-005 | covered | `src/db/repo.rs`, `src/ingest.rs` | Never accept, store, or serve gallery data — reference faces or embed… | | UR-013 | Done | T1 | SR-002 | covered | `src/api/fetch.rs`, `src/model.rs`, `src/validate.rs` | Windows are scene-scoped claims; never reinterpret their boundaries | | UR-014 | Done | T1 | SR-003 | covered | `src/model.rs`, `src/validate.rs` | Reject an unknown `jmanifest_version` outright, never guess | -| UR-015 | Planned | unset | SR-003 | untagged | - | Accept `extraction.extinction_sec` in place of `anneal_sec` | -| UR-016 | Planned | unset | SR-003 | untagged | - | Accept and store `extraction.gallery_scope`; rank on it (§7) | -| UR-017 | Planned | unset | SR-003 | untagged | - | Accept per-window belief and identification route; `scenes` becomes o… | -| UR-018 | Planned | unset | SR-003 | untagged | - | Exclude belief from `content_id`, replicating it as an attribute | +| UR-015 | Done | T2 | SR-003 | untagged | - | Accept `extraction.extinction_sec` in place of `anneal_sec` | +| UR-016 | Done | T2 | SR-003 | untagged | - | Accept and store `extraction.gallery_scope`; rank on it (§7) | +| UR-017 | Done | T1, T2 | SR-003 | covered | `src/model.rs` | Accept per-window belief and identification route; `scenes` are objec… | +| UR-018 | Done | T1 | SR-003 | untagged | - | Exclude belief and route from `content_id`, replicating them as attri… | | DR-001 | Done | T1 | SR-004 | untagged | - | Strict parse boundary: unknown fields rejected structurally, not by v… | | DR-002 | Done | unset | SR-004 | covered | `src/api/fetch.rs`, `src/db/repo.rs` | Fully relational storage — no JSON blob on the write path | | DR-003 | Done | T1 | PR-004 | covered | `src/db/mod.rs` | Single serialized writer connection, with a read pool alongside | @@ -100,7 +100,7 @@ _None._ - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` -- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(` +- [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(` ### DR-003 @@ -118,7 +118,7 @@ _None._ **Locations:** 1 -- [`src/db/repo.rs:691`](../src/db/repo.rs#L691) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result, now: &str, limit: usize) -> anyhow::Result i64` +- [`src/validate.rs:102`](../src/validate.rs#L102) — `pub fn to_centiseconds(secs: f64) -> i64` ### DR-013 @@ -167,7 +167,7 @@ _None._ - [`src/config.rs:10`](../src/config.rs#L10) — `Unknown` - [`src/db/mod.rs:30`](../src/db/mod.rs#L30) — `struct ReadPool` -- [`src/db/repo.rs:691`](../src/db/repo.rs#L691) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result, now: &str, limit: usize) -> anyhow::Result VResult<()>` +- [`src/validate.rs:586`](../src/validate.rs#L586) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>` ### SR-001 @@ -193,30 +193,33 @@ _None._ - [`src/api/exists.rs:74`](../src/api/exists.rs#L74) — `pub async fn exists_batch(` - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` -- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(` +- [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(` - [`src/matching.rs:52`](../src/matching.rs#L52) — `pub fn match_cut(client: &ClientCut, stored: &StoredCut) -> Option` - [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` ### SR-002 -**Locations:** 3 +**Locations:** 4 - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` -- [`src/validate.rs:486`](../src/validate.rs#L486) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` +- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option` +- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` ### SR-003 -**Locations:** 8 +**Locations:** 10 - [`src/api/json.rs:100`](../src/api/json.rs#L100) — `fn require_utf8(bytes: &[u8]) -> Result<&str, ApiError>` - [`src/content_id.rs:52`](../src/content_id.rs#L52) — `pub fn canonical_json(` - [`src/content_id.rs:129`](../src/content_id.rs#L129) — `pub fn content_id(` - [`src/error.rs:8`](../src/error.rs#L8) — `Unknown` -- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` -- [`src/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> i64` -- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` -- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` +- [`src/model.rs:197`](../src/model.rs#L197) — `Unknown` +- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` +- [`src/validate.rs:102`](../src/validate.rs#L102) — `pub fn to_centiseconds(secs: f64) -> i64` +- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` ### SR-004 @@ -232,11 +235,11 @@ _None._ - [`src/castcheck.rs:215`](../src/castcheck.rs#L215) — `pub fn category_guard_violation(matched: &[MatchedActor], title_is_adult: bool) -> Option…` - [`src/db/repo.rs:337`](../src/db/repo.rs#L337) — `pub fn insert_manifest(tx: &Transaction<'_>, m: &NewManifest<'_>) -> anyhow::Result<()>` - [`src/model.rs:156`](../src/model.rs#L156) — `Unknown` -- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` - [`src/ratelimit.rs:76`](../src/ratelimit.rs#L76) — `impl Default for RateLimiter` -- [`src/validate.rs:112`](../src/validate.rs#L112) — `fn is_allowed_text_char(c: char) -> bool` -- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` -- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` +- [`src/validate.rs:136`](../src/validate.rs#L136) — `fn is_allowed_text_char(c: char) -> bool` +- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` - [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` ### SR-005 @@ -268,8 +271,8 @@ _None._ - [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(` - [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` - [`src/model.rs:156`](../src/model.rs#L156) — `Unknown` -- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` -- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` +- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` - [`src/worker.rs:96`](../src/worker.rs#L96) — `async fn run_cast_check(&self, payload: &str) -> Result<(), JobError>` ### UR-004 @@ -296,7 +299,7 @@ _None._ - [`src/api/fetch.rs:128`](../src/api/fetch.rs#L128) — `pub async fn get_series(` - [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(` -- [`src/validate.rs:540`](../src/validate.rs#L540) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>` +- [`src/validate.rs:586`](../src/validate.rs#L586) — `pub fn validate_bundle_envelope(b: &SeriesBundle) -> VResult<()>` ### UR-007 @@ -308,7 +311,7 @@ _None._ **Locations:** 1 -- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` +- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` ### UR-010 @@ -316,7 +319,7 @@ _None._ - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/castcheck.rs:82`](../src/castcheck.rs#L82) — `pub fn evaluate(submitted: &[SubmittedActor], credits: &[CastMember]) -> CastCheckOutcome` -- [`src/db/repo.rs:405`](../src/db/repo.rs#L405) — `pub fn actors_for_manifest(` +- [`src/db/repo.rs:412`](../src/db/repo.rs#L412) — `pub fn actors_for_manifest(` - [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` ### UR-011 @@ -324,9 +327,9 @@ _None._ **Locations:** 4 - [`src/model.rs:156`](../src/model.rs#L156) — `Unknown` -- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` -- [`src/validate.rs:112`](../src/validate.rs#L112) — `fn is_allowed_text_char(c: char) -> bool` -- [`src/validate.rs:354`](../src/validate.rs#L354) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` +- [`src/validate.rs:136`](../src/validate.rs#L136) — `fn is_allowed_text_char(c: char) -> bool` +- [`src/validate.rs:378`](../src/validate.rs#L378) — `pub fn validate_audio_signature(sig: &str, runtime_sec: f64) -> VResult<()>` ### UR-012 @@ -337,16 +340,24 @@ _None._ ### UR-013 -**Locations:** 3 +**Locations:** 4 - [`src/api/fetch.rs:267`](../src/api/fetch.rs#L267) — `pub fn reconstruct(` - [`src/model.rs:179`](../src/model.rs#L179) — `Unknown` -- [`src/validate.rs:486`](../src/validate.rs#L486) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` +- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option` +- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult>` ### UR-014 **Locations:** 2 -- [`src/model.rs:198`](../src/model.rs#L198) — `Unknown` -- [`src/validate.rs:203`](../src/validate.rs#L203) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` +- [`src/model.rs:259`](../src/model.rs#L259) — `Unknown` +- [`src/validate.rs:227`](../src/validate.rs#L227) — `pub fn validate_manifest(mut m: Jmanifest) -> VResult` + +### UR-017 + +**Locations:** 2 + +- [`src/model.rs:197`](../src/model.rs#L197) — `Unknown` +- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option` diff --git a/src/api/fetch.rs b/src/api/fetch.rs index 0aeb2fb..917fc00 100644 --- a/src/api/fetch.rs +++ b/src/api/fetch.rs @@ -17,7 +17,7 @@ use crate::error::{ApiError, ApiResult}; use crate::matching::{self, StoredCut}; use crate::model::{ Actor, Coverage, Cut, Extraction, GalleryScope, Identity, IdentityType, Jmanifest, MatchTier, - SeriesBundle, SeriesRef, JMANIFEST_VERSION, + Scene, SeriesBundle, SeriesRef, JMANIFEST_VERSION, }; use crate::ratelimit::Surface; use crate::state::{with_quota_headers, AppState}; @@ -282,7 +282,12 @@ pub fn reconstruct( scenes: a .scenes_cs .into_iter() - .map(|(s, e)| [s as f64 / 100.0, e as f64 / 100.0]) + .map(|s| Scene { + start: s.start_cs as f64 / 100.0, + end: s.end_cs as f64 / 100.0, + belief: s.belief, + route: s.route, + }) .collect(), }) .collect(); diff --git a/src/db/repo.rs b/src/db/repo.rs index 560702b..7ec5b7f 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -374,7 +374,7 @@ pub fn insert_actor_scenes( tx: &Transaction<'_>, manifest_id: &str, tmdb_person_id: u64, - scenes_cs: &[(i64, i64)], + scenes_cs: &[crate::validate::SceneCs], ) -> anyhow::Result<()> { tx.execute( "INSERT INTO manifest_actors (manifest_id, tmdb_person_id) VALUES (?1, ?2) @@ -382,11 +382,18 @@ pub fn insert_actor_scenes( params![manifest_id, tmdb_person_id as i64], )?; let mut stmt = tx.prepare_cached( - "INSERT INTO scenes (manifest_id, tmdb_person_id, start_cs, end_cs) - VALUES (?1, ?2, ?3, ?4)", + "INSERT INTO scenes (manifest_id, tmdb_person_id, start_cs, end_cs, belief, route) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", )?; - for (start, end) in scenes_cs { - stmt.execute(params![manifest_id, tmdb_person_id as i64, start, end])?; + for scene in scenes_cs { + stmt.execute(params![ + manifest_id, + tmdb_person_id as i64, + scene.start_cs, + scene.end_cs, + scene.belief, + scene.route.map(|r| r.as_str()), + ])?; } Ok(()) } @@ -399,7 +406,7 @@ pub fn insert_actor_scenes( pub struct StoredActor { pub tmdb_person_id: u64, pub name: Option, - pub scenes_cs: Vec<(i64, i64)>, + pub scenes_cs: Vec, } /// TRACES: UR-010 | DR-002 | SR-001 @@ -421,7 +428,7 @@ pub fn actors_for_manifest( .collect::>>()?; let mut scene_stmt = conn.prepare_cached( - "SELECT start_cs, end_cs FROM scenes + "SELECT start_cs, end_cs, belief, route FROM scenes WHERE manifest_id = ?1 AND tmdb_person_id = ?2 ORDER BY start_cs ASC", )?; @@ -429,7 +436,18 @@ pub fn actors_for_manifest( let mut out = Vec::with_capacity(people.len()); for (id, name) in people { let scenes_cs = scene_stmt - .query_map(params![manifest_id, id as i64], |r| Ok((r.get(0)?, r.get(1)?)))? + .query_map(params![manifest_id, id as i64], |r| { + Ok(crate::validate::SceneCs { + start_cs: r.get(0)?, + end_cs: r.get(1)?, + belief: r.get(2)?, + // An unrecognised stored route means a row from a schema + // this build does not know; report absent rather than guess. + route: r + .get::<_, Option>(3)? + .and_then(|v| crate::model::Route::from_stored(&v)), + }) + })? .collect::>>()?; out.push(StoredActor { tmdb_person_id: id, name, scenes_cs }); } @@ -742,6 +760,7 @@ pub fn release_all_leases(tx: &Transaction<'_>) -> anyhow::Result { mod tests { use super::*; use crate::db::Db; + use crate::validate::SceneCs; async fn db() -> Db { Db::open(":memory:").unwrap() @@ -837,7 +856,12 @@ mod tests { }, )?; upsert_person(tx, 884, "Steve Buscemi", false, NOW)?; - insert_actor_scenes(tx, &id, 884, &[(19160, 20920), (43820, 46560)])?; + insert_actor_scenes( + tx, + &id, + 884, + &[SceneCs::plain(19160, 20920), SceneCs::plain(43820, 46560)], + )?; Ok(id) }) .await @@ -857,7 +881,10 @@ mod tests { assert_eq!(actors[0].tmdb_person_id, 884); // §7: names come from `people`, populated from TMDB, never from upload. assert_eq!(actors[0].name.as_deref(), Some("Steve Buscemi")); - assert_eq!(actors[0].scenes_cs, vec![(19160, 20920), (43820, 46560)]); + assert_eq!( + actors[0].scenes_cs, + vec![SceneCs::plain(19160, 20920), SceneCs::plain(43820, 46560)] + ); } #[tokio::test] @@ -1097,7 +1124,7 @@ mod tests { }, )?; upsert_person(tx, 1, "A", false, NOW)?; - insert_actor_scenes(tx, "m", 1, &[(0, 100)])?; + insert_actor_scenes(tx, "m", 1, &[SceneCs::plain(0, 100)])?; delete_manifest(tx, "m")?; let scenes: i64 = tx.query_row("SELECT COUNT(*) FROM scenes", [], |r| r.get(0))?; let actors: i64 = diff --git a/src/db/schema.sql b/src/db/schema.sql index 3a2afe4..92b4d13 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -70,7 +70,13 @@ CREATE TABLE IF NOT EXISTS scenes ( manifest_id TEXT NOT NULL REFERENCES manifests(id) ON DELETE CASCADE, tmdb_person_id INTEGER NOT NULL, start_cs INTEGER NOT NULL, - end_cs INTEGER NOT NULL + end_cs INTEGER NOT NULL, + -- Per-window provenance (SR-003, extraction AR-017). Deliberately NOT part + -- of `content_id`: belief is a producer-side estimate that may differ + -- between pipeline versions for identical timings, so hashing it would give + -- two servers different ids for the same content (§9a). + belief REAL, + route TEXT -- live | deferred | pooled ); CREATE TABLE IF NOT EXISTS reports ( diff --git a/src/ingest.rs b/src/ingest.rs index 2b60269..fcd00ab 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -164,8 +164,16 @@ pub fn compute_content_id(valid: &ValidManifest) -> String { .actor_scenes_cs .iter() .filter_map(|a| { - a.tmdb_id - .map(|id| CanonicalActor { tmdb_person_id: id, scenes_cs: a.scenes_cs.clone() }) + a.tmdb_id.map(|id| CanonicalActor { + tmdb_person_id: id, + // Timings only. §9a excludes belief and route from identity: + // they are producer-side estimates that may differ between + // pipeline versions for identical content, so hashing them + // would give two servers different ids for the same + // manifest — the failure mode centisecond quantisation + // exists to remove. They replicate as attributes instead. + scenes_cs: a.scenes_cs.iter().map(|s| (s.start_cs, s.end_cs)).collect(), + }) }) .collect(); @@ -188,12 +196,12 @@ mod tests { fn movie_json(tmdb: &str, runtime: f64) -> String { format!( - r#"{{"jmanifest_version":1, + r#"{{"jmanifest_version":2, "identity":{{"type":"movie","tmdb_id":"{tmdb}","title":"A Film"}}, "cut":{{"runtime_sec":{runtime}}}, "extraction":{{"sample_fps":5,"pipeline_version":"test 0.1"}}, - "actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]}}, - {{"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}}]}}"# + "actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{{"start":10.0,"end":20.0}}]}}, + {{"name":"Michael Palin","tmdb_id":"11007","scenes":[{{"start":30.0,"end":40.0}}]}}]}}"# ) } @@ -270,10 +278,10 @@ mod tests { // so this exercises the per-contributor 409 path specifically. let a = valid_from(&movie_json("504172", 6420.5)); let b = valid_from( - r#"{"jmanifest_version":1, + r#"{"jmanifest_version":2, "identity":{"type":"movie","tmdb_id":"504172","title":"A Film"}, "cut":{"runtime_sec":6420.5}, - "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[11.0,21.0]]}]}"#, + "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":11.0,"end":21.0}]}]}"#, ); let second = db @@ -315,11 +323,11 @@ mod tests { async fn episode_manifests_carry_their_coordinates() { let db = Db::open(":memory:").unwrap(); let valid = valid_from( - r#"{"jmanifest_version":1, + r#"{"jmanifest_version":2, "identity":{"type":"episode","series_tmdb_id":"1396","title":"Breaking Bad", "season":2,"episode":5}, "cut":{"runtime_sec":2820.0}, - "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[[10.0,20.0]]}]}"#, + "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[{"start":10.0,"end":20.0}]}]}"#, ); let row = db .write(move |tx| { @@ -357,13 +365,13 @@ mod tests { // servers validating the same upload agree. let a = valid_from(&movie_json("504172", 6420.5)); let b = valid_from( - r#"{"jmanifest_version":1, + r#"{"jmanifest_version":2, "identity":{"type":"movie","tmdb_id":"504172","title":"A Film"}, "cut":{"runtime_sec":6420.5}, "extraction":{"sample_fps":1,"extinction_sec":9,"pipeline_version":"other 9.9", "gallery_size":5}, - "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]}, - {"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}]}"#, + "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":10.0,"end":20.0}]}, + {"name":"Michael Palin","tmdb_id":"11007","scenes":[{"start":30.0,"end":40.0}]}]}"#, ); assert_eq!(compute_content_id(&a), compute_content_id(&b)); } @@ -375,29 +383,75 @@ mod tests { let a = valid_from(&movie_json("504172", 6420.5)); let sig = format!("v1:{}", "A".repeat(1720)); let with_sig = format!( - r#"{{"jmanifest_version":1, + r#"{{"jmanifest_version":2, "identity":{{"type":"movie","tmdb_id":"504172","title":"A Film"}}, "cut":{{"runtime_sec":6420.5,"audio_signature":"{sig}"}}, "extraction":{{"sample_fps":5,"pipeline_version":"test 0.1"}}, - "actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[10.0,20.0]]}}, - {{"name":"Michael Palin","tmdb_id":"11007","scenes":[[30.0,40.0]]}}]}}"# + "actors":[{{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{{"start":10.0,"end":20.0}}]}}, + {{"name":"Michael Palin","tmdb_id":"11007","scenes":[{{"start":30.0,"end":40.0}}]}}]}}"# ); let b = valid_from(&with_sig); assert_eq!(compute_content_id(&a), compute_content_id(&b)); } + #[test] + fn content_id_excludes_belief_and_route() { + // The trap in the SR-003 bump (UR-018). Belief is a producer-side + // estimate that may legitimately differ between pipeline versions for + // identical timings, so hashing it would give two servers different ids + // for the same manifest — the exact failure mode §9a quantises + // centiseconds to avoid, reintroduced one field along. + // + // Two manifests, same windows, wildly different confidence and routes. + let bare = r#"{"jmanifest_version":2, + "identity":{"type":"movie","tmdb_id":"504172","title":"A Film"}, + "cut":{"runtime_sec":6420.5}, + "actors":[{"name":"Steve Buscemi","tmdb_id":"884", + "scenes":[{"start":10.0,"end":20.0}]}, + {"name":"Michael Palin","tmdb_id":"11007", + "scenes":[{"start":30.0,"end":40.0}]}]}"#; + let believed = r#"{"jmanifest_version":2, + "identity":{"type":"movie","tmdb_id":"504172","title":"A Film"}, + "cut":{"runtime_sec":6420.5}, + "actors":[{"name":"Steve Buscemi","tmdb_id":"884", + "scenes":[{"start":10.0,"end":20.0,"belief":0.98,"route":"live"}]}, + {"name":"Michael Palin","tmdb_id":"11007", + "scenes":[{"start":30.0,"end":40.0,"belief":0.31,"route":"deferred"}]}]}"#; + + assert_eq!( + compute_content_id(&valid_from(bare)), + compute_content_id(&valid_from(believed)), + "belief and route must not enter identity" + ); + + // And the same content at a *different* belief still deduplicates. + let other_belief = believed.replace("0.98", "0.42").replace("live", "pooled"); + assert_eq!( + compute_content_id(&valid_from(believed)), + compute_content_id(&valid_from(&other_belief)), + ); + + // Sanity: a genuine timing change *does* alter the id, so the test above + // is not passing because the hash ignores everything. + let shifted = bare.replace("\"end\":20.0", "\"end\":21.0"); + assert_ne!( + compute_content_id(&valid_from(bare)), + compute_content_id(&valid_from(&shifted)) + ); + } + #[test] fn content_id_excludes_submitted_names() { // Names are not persisted, so they must not be part of identity either — // otherwise a renamed resubmission would evade deduplication. let a = valid_from(&movie_json("504172", 6420.5)); let b = valid_from( - r#"{"jmanifest_version":1, + r#"{"jmanifest_version":2, "identity":{"type":"movie","tmdb_id":"504172","title":"A Film"}, "cut":{"runtime_sec":6420.5}, "extraction":{"sample_fps":5,"pipeline_version":"test 0.1"}, - "actors":[{"name":"Someone Else","tmdb_id":"884","scenes":[[10.0,20.0]]}, - {"name":"Another Person","tmdb_id":"11007","scenes":[[30.0,40.0]]}]}"#, + "actors":[{"name":"Someone Else","tmdb_id":"884","scenes":[{"start":10.0,"end":20.0}]}, + {"name":"Another Person","tmdb_id":"11007","scenes":[{"start":30.0,"end":40.0}]}]}"#, ); assert_eq!(compute_content_id(&a), compute_content_id(&b)); } diff --git a/src/model.rs b/src/model.rs index 7c92799..e50b956 100644 --- a/src/model.rs +++ b/src/model.rs @@ -190,8 +190,74 @@ pub struct Actor { /// The **primary** actor join key (§2, §6 stage 3). #[serde(default, skip_serializing_if = "Option::is_none")] pub tmdb_id: Option, - /// `[start_sec, end_sec]` inclusive, sorted. - pub scenes: Vec<[f64; 2]>, + /// Presence windows, inclusive and sorted by start. + pub scenes: Vec, +} + +/// TRACES: UR-017 | SR-003 +/// How an actor was identified for a given window (`scene-actor-extraction` +/// AR-017: every presence claim carries its belief and identification route). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Route { + /// Identified while the track was live. + Live, + /// Resolved by the deferred pass, after the final frame was read. + Deferred, + /// Resolved from the per-subject embedding pool. + Pooled, +} + +impl Route { + pub fn as_str(self) -> &'static str { + match self { + Route::Live => "live", + Route::Deferred => "deferred", + Route::Pooled => "pooled", + } + } + + /// Parses a value read back from storage. Returns `None` for anything + /// unrecognised rather than guessing — a row written by a future schema + /// means something this build does not know, and inventing a route would + /// misreport provenance. + /// + /// Deliberately not `FromStr`: that trait is for parsing *input*, and this + /// reads a value the server itself wrote from a closed enum. Keeping them + /// distinct stops a future refactor pointing user input at this path. + pub fn from_stored(s: &str) -> Option { + match s { + "live" => Some(Route::Live), + "deferred" => Some(Route::Deferred), + "pooled" => Some(Route::Pooled), + _ => None, + } + } +} + +/// TRACES: UR-013, UR-017 | SR-002, SR-003 +/// One presence window. +/// +/// **A window is a claim about scene membership, not a recognition event** +/// (SR-002, UR-013). An actor who turns away or is off-camera during a reverse +/// shot is still present, so the server never merges, splits or trims these — +/// it stores what it was given, quantised but not reshaped. +/// +/// `belief` and `route` are **excluded from `content_id`** (§9a). Belief is a +/// producer-side estimate that may legitimately differ between pipeline versions +/// for identical timings, so including it would give two servers different ids +/// for the same content — the exact failure mode §9a quantises centiseconds to +/// avoid. They replicate as attributes, exactly as `audio_signature` does. +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Scene { + pub start: f64, + pub end: f64, + /// Accumulated posterior that justified the claim, in `[0, 1]`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub belief: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub route: Option, } /// One shareable actor timeline for one cut of one title (§2). @@ -240,7 +306,21 @@ pub struct Coverage { } /// The current `jmanifest_version` this server speaks (§2). -pub const JMANIFEST_VERSION: u32 = 1; +/// +/// Bumped to 2 with the SR-003 schema change, in lockstep with the truth file's +/// `schema_version` — breaking changes are batched and ship together across all +/// three repos, so a component moving alone is the defect this coordination +/// exists to prevent. +/// +/// **Flag day, not dual-accept** (SR-003, `jRay` JR-003). Version 1 is rejected +/// outright rather than carried alongside: all three components are pre-release, +/// and a v1 read path would be the one nobody exercises, so it is the one that +/// would rot while being dragged through every later change to the reader. +/// +/// The two version fields remain *independent by design* — `jmanifest_version` +/// versions the exchange envelope, `schema_version` the truth file — and they +/// coincide at 2 only because this bump touched both. +pub const JMANIFEST_VERSION: u32 = 2; #[cfg(test)] mod tests { @@ -248,7 +328,7 @@ mod tests { #[test] fn unknown_field_at_top_level_is_rejected() { - let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"}, + let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"}, "cut":{"runtime_sec":100.0},"actors":[],"surprise":"x"}"#; let err = serde_json::from_str::(json).unwrap_err().to_string(); assert!(err.contains("surprise"), "error should name the field: {err}"); @@ -258,7 +338,7 @@ mod tests { fn jellyfin_id_on_an_actor_is_a_parse_error() { // §2: `actors[].jellyfin_id` must not appear. `deny_unknown_fields` // makes this structural rather than a validator's responsibility. - let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"}, + let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"}, "cut":{"runtime_sec":100.0}, "actors":[{"name":"A","tmdb_id":"2","jellyfin_id":"guid","scenes":[]}]}"#; let err = serde_json::from_str::(json).unwrap_err().to_string(); @@ -267,7 +347,7 @@ mod tests { #[test] fn movie_path_field_is_a_parse_error() { - let json = r#"{"jmanifest_version":1,"movie":"/data/movies/x.mkv", + let json = r#"{"jmanifest_version":2,"movie":"/data/movies/x.mkv", "identity":{"type":"movie","tmdb_id":"1"}, "cut":{"runtime_sec":100.0},"actors":[]}"#; let err = serde_json::from_str::(json).unwrap_err().to_string(); @@ -276,7 +356,7 @@ mod tests { #[test] fn unknown_field_nested_in_cut_is_rejected() { - let json = r#"{"jmanifest_version":1,"identity":{"type":"movie","tmdb_id":"1"}, + let json = r#"{"jmanifest_version":2,"identity":{"type":"movie","tmdb_id":"1"}, "cut":{"runtime_sec":100.0,"payload":"x"},"actors":[]}"#; assert!(serde_json::from_str::(json).is_err()); } @@ -284,7 +364,7 @@ mod tests { #[test] fn spec_example_manifest_parses() { let json = r#"{ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": "504172", "imdb_id": "tt4686844", "title": "The Death of Stalin", "year": 2017 }, "cut": { "runtime_sec": 6420.5, "container_duration_sec": 6420.5, @@ -293,7 +373,10 @@ mod tests { "pipeline_version": "scene-actor-extraction 0.4.1", "gallery_size": 1820, "gallery_scope": "global" }, "actors": [ { "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884", - "scenes": [[191.6, 209.2], [438.2, 465.6]] } ] + "scenes": [ + { "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" }, + { "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" } + ] } ] }"#; let m: Jmanifest = serde_json::from_str(json).unwrap(); assert_eq!(m.actors.len(), 1); diff --git a/src/validate.rs b/src/validate.rs index 15cd217..5515120 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -7,7 +7,9 @@ use unicode_general_category::{get_general_category, GeneralCategory}; use unicode_normalization::{is_nfc, UnicodeNormalization}; -use crate::model::{Actor, Identity, IdentityType, Jmanifest, SeriesBundle, JMANIFEST_VERSION}; +use crate::model::{ + Actor, Identity, IdentityType, Jmanifest, Route, SeriesBundle, JMANIFEST_VERSION, +}; /// §6 stage 1 caps. Body-size limits are applied as a layer (see [`crate::app`]); /// these are the structural counts the schema layer enforces. @@ -60,13 +62,35 @@ pub struct ValidManifest { pub actor_scenes_cs: Vec, } +/// One validated window, quantised and carrying its provenance. +/// +/// `belief` and `route` ride alongside `start_cs`/`end_cs` rather than being +/// folded into them, because §9a hashes only the timings: belief is a +/// producer-side estimate that may differ between pipeline versions for +/// identical content, so it is replicated as an attribute, never as identity. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SceneCs { + pub start_cs: i64, + pub end_cs: i64, + pub belief: Option, + pub route: Option, +} + +impl SceneCs { + /// A window carrying timings only — the shape a producer that has not yet + /// adopted per-window belief emits, and the one `content_id` hashes. + pub fn plain(start_cs: i64, end_cs: i64) -> Self { + Self { start_cs, end_cs, belief: None, route: None } + } +} + #[derive(Debug, Clone)] pub struct ActorScenes { /// NFC-normalised name, used only for matching in stage 3 and then dropped. pub name: Option, pub tmdb_id: Option, pub imdb_id: Option, - pub scenes_cs: Vec<(i64, i64)>, + pub scenes_cs: Vec, } /// Quantises seconds to whole centiseconds (§9a). @@ -484,7 +508,7 @@ fn validate_actors(m: &Jmanifest) -> VResult> { } /// TRACES: UR-013 | SR-002 -fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult> { +fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult> { if a.scenes.len() > limits::MAX_SCENES_PER_ACTOR { return Err(err( format!("actors[{idx}].scenes"), @@ -494,8 +518,9 @@ fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult VResult Result, &'static str> { #[cfg(test)] mod tests { use super::*; + use crate::model::Scene; + + /// A window with timings only — belief and route are exercised separately. + fn scene(start: f64, end: f64) -> Scene { + Scene { start, end, belief: None, route: None } + } fn base_manifest() -> Jmanifest { serde_json::from_str( - r#"{"jmanifest_version":1, + r#"{"jmanifest_version":2, "identity":{"type":"movie","tmdb_id":"504172","title":"The Death of Stalin"}, "cut":{"runtime_sec":6420.5}, - "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[[191.6,209.2]]}]}"#, + "actors":[{"name":"Steve Buscemi","tmdb_id":"884","scenes":[{"start":191.6,"end":209.2}]}]}"#, ) .unwrap() } @@ -656,7 +708,7 @@ mod tests { fn accepts_a_realistic_manifest() { let v = validate_manifest(base_manifest()).unwrap(); assert_eq!(v.actor_scenes_cs.len(), 1); - assert_eq!(v.actor_scenes_cs[0].scenes_cs, vec![(19160, 20920)]); + assert_eq!(v.actor_scenes_cs[0].scenes_cs, vec![SceneCs::plain(19160, 20920)]); } #[test] @@ -672,15 +724,20 @@ mod tests { // left and returned (two windows). let mut m = base_manifest(); m.actors[0].scenes = vec![ - [10.0, 20.0], - [20.0, 30.0], // exactly adjacent — must stay separate - [30.01, 40.0], // a hair's gap — likewise - [100.0, 100.0], // zero-length — a real producer emits these + scene(10.0, 20.0), + scene(20.0, 30.0), // exactly adjacent — must stay separate + scene(30.01, 40.0), // a hair's gap — likewise + scene(100.0, 100.0), // zero-length — a real producer emits these ]; let v = validate_manifest(m).unwrap(); assert_eq!( v.actor_scenes_cs[0].scenes_cs, - vec![(1000, 2000), (2000, 3000), (3001, 4000), (10000, 10000)], + vec![ + SceneCs::plain(1000, 2000), + SceneCs::plain(2000, 3000), + SceneCs::plain(3001, 4000), + SceneCs::plain(10000, 10000) + ], "windows must survive validation unchanged apart from quantisation" ); } @@ -723,7 +780,7 @@ mod tests { #[test] fn rejects_scene_beyond_runtime_tolerance() { let mut m = base_manifest(); - m.actors[0].scenes = vec![[10.0, 6500.0]]; + m.actors[0].scenes = vec![scene(10.0, 6500.0)]; let e = validate_manifest(m).unwrap_err(); assert!(e.field.starts_with("actors[0].scenes"), "got {}", e.field); } @@ -731,17 +788,17 @@ mod tests { #[test] fn accepts_scene_within_runtime_tolerance() { let mut m = base_manifest(); - m.actors[0].scenes = vec![[10.0, 6424.0]]; + m.actors[0].scenes = vec![scene(10.0, 6424.0)]; assert!(validate_manifest(m).is_ok()); } #[test] fn rejects_end_before_start_and_negative_and_nonfinite() { for scenes in [ - vec![[50.0, 10.0]], - vec![[-1.0, 10.0]], - vec![[f64::NAN, 10.0]], - vec![[0.0, f64::INFINITY]], + vec![scene(50.0, 10.0)], + vec![scene(-1.0, 10.0)], + vec![scene(f64::NAN, 10.0)], + vec![scene(0.0, f64::INFINITY)], ] { let mut m = base_manifest(); m.actors[0].scenes = scenes; @@ -752,7 +809,7 @@ mod tests { #[test] fn rejects_unsorted_scenes() { let mut m = base_manifest(); - m.actors[0].scenes = vec![[100.0, 120.0], [10.0, 20.0]]; + m.actors[0].scenes = vec![scene(100.0, 120.0), scene(10.0, 20.0)]; let e = validate_manifest(m).unwrap_err(); assert_eq!(e.field, "actors[0].scenes"); } @@ -791,10 +848,10 @@ mod tests { #[test] fn episode_identity_requires_season_and_episode() { - let json = r#"{"jmanifest_version":1, + let json = r#"{"jmanifest_version":2, "identity":{"type":"episode","series_tmdb_id":"1396","title":"Breaking Bad"}, "cut":{"runtime_sec":2820.0}, - "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[[10.0,20.0]]}]}"#; + "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[{"start":10.0,"end":20.0}]}]}"#; let m: Jmanifest = serde_json::from_str(json).unwrap(); let e = validate_manifest(m).unwrap_err(); assert_eq!(e.field, "identity.season"); @@ -802,11 +859,11 @@ mod tests { #[test] fn valid_episode_identity_is_accepted() { - let json = r#"{"jmanifest_version":1, + let json = r#"{"jmanifest_version":2, "identity":{"type":"episode","series_tmdb_id":"1396","series_imdb_id":"tt0903747", "title":"Breaking Bad","season":2,"episode":5}, "cut":{"runtime_sec":2820.0}, - "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[[10.0,20.0]]}]}"#; + "actors":[{"name":"Bryan Cranston","tmdb_id":"17419","scenes":[{"start":10.0,"end":20.0}]}]}"#; let m: Jmanifest = serde_json::from_str(json).unwrap(); assert!(validate_manifest(m).is_ok()); } @@ -1040,12 +1097,12 @@ mod tests { #[test] fn bundle_envelope_checks() { - let ok = r#"{"jmanifest_version":1, + let ok = r#"{"jmanifest_version":2, "series":{"series_tmdb_id":"1396","title":"Breaking Bad"}, - "episodes":[{"jmanifest_version":1, + "episodes":[{"jmanifest_version":2, "identity":{"type":"episode","series_tmdb_id":"1396","season":1,"episode":1}, "cut":{"runtime_sec":2820.0}, - "actors":[{"tmdb_id":"17419","scenes":[[1.0,2.0]]}]}]}"#; + "actors":[{"tmdb_id":"17419","scenes":[{"start":1.0,"end":2.0}]}]}]}"#; let b: SeriesBundle = serde_json::from_str(ok).unwrap(); assert!(validate_bundle_envelope(&b).is_ok()); diff --git a/tests/api.rs b/tests/api.rs index e6dc1a0..a80afaa 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -149,7 +149,7 @@ impl TestServer { fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value { json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "The Death of Stalin", "year": 2017 }, "cut": { "runtime_sec": runtime, "video_hash": "opensubtitles:8e245d9679d31e12" }, @@ -157,8 +157,8 @@ fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value { "pipeline_version": "scene-actor-extraction 0.4.1", "gallery_scope": "global" }, "actors": [ - { "name": "Steve Buscemi", "tmdb_id": "884", "scenes": [[191.6, 209.2], [438.2, 465.6]] }, - { "name": "Michael Palin", "tmdb_id": "11007", "scenes": [[300.0, 320.0]] } + { "name": "Steve Buscemi", "tmdb_id": "884", "scenes": [{"start":191.6,"end":209.2},{"start":438.2,"end":465.6}] }, + { "name": "Michael Palin", "tmdb_id": "11007", "scenes": [{"start":300.0,"end":320.0}] } ] }) } @@ -341,6 +341,39 @@ async fn the_schema_bump_fields_round_trip() { assert_eq!(status, StatusCode::BAD_REQUEST, "gallery_scope is a closed enum"); } +#[tokio::test] +async fn per_window_belief_and_route_round_trip() { + // SR-003 gives each window its posterior and identification route + // (extraction AR-017). They are stored and served — a consumer needs them to + // know how much to trust a window — but they are never part of identity. + let s = TestServer::new("belief"); + let token = s.token().await; + + let mut m = movie_manifest("504172", 6420.5); + m["actors"][0]["scenes"] = json!([ + { "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" }, + { "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" } + ]); + let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + + // A belief outside [0, 1] is not a probability. Bounded rather than merely + // stored, because §5a's Threat 1 argument rests on every accepted value + // being bounded — an unbounded float is a 64-bit channel. + for bad in [-0.1, 1.5] { + let mut m = movie_manifest("504173", 6420.5); + m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "belief": bad }]); + let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "belief {bad}: {body}"); + } + + // `route` is a closed vocabulary, so an invented value cannot be stored. + let mut m = movie_manifest("504174", 6420.5); + m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "route": "telepathy" }]); + let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn an_unknown_manifest_version_is_rejected() { // UR-014 / SR-003: a consumer encountering an unknown `schema_version` @@ -348,7 +381,11 @@ async fn an_unknown_manifest_version_is_rejected() { let s = TestServer::new("version"); let token = s.token().await; - for version in [0, 2, 99] { + // Version 1 is the one that matters: SR-003 settled on a **flag day**, not a + // dual-accept period, so the immediately-previous version is refused exactly + // like a nonsensical one. A v1 read path would be the one nobody exercises, + // and so the one that rots while being dragged through every later change. + for version in [0, 1, 3, 99] { let mut m = movie_manifest("504172", 6420.5); m["jmanifest_version"] = json!(version); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; @@ -560,7 +597,7 @@ async fn exceeding_a_limit_returns_429_with_retry_after() { // The bundle surface has the tightest write limit (20/hour), so it is the // cheapest to exhaust. let bundle = json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "series": { "series_tmdb_id": "1396", "title": "Breaking Bad" }, "episodes": [] }); @@ -688,24 +725,24 @@ async fn bundle_upload_is_not_atomic() { let good = |ep: i64| { json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "1396", "title": "Breaking Bad", "season": 1, "episode": ep }, "cut": { "runtime_sec": 2820.0 }, "actors": [ { "name": "Bryan Cranston", "tmdb_id": "17419", - "scenes": [[10.0, 20.0]] } ] + "scenes": [{"start":10.0,"end":20.0}] } ] }) }; // Invalid: a scene window beyond the runtime tolerance (§6). let bad = json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": 3 }, "cut": { "runtime_sec": 2820.0 }, - "actors": [ { "tmdb_id": "17419", "scenes": [[10.0, 99999.0]] } ] + "actors": [ { "tmdb_id": "17419", "scenes": [{"start":10.0,"end":99999.0}] } ] }); let bundle = json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "series": { "series_tmdb_id": "1396", "title": "Breaking Bad" }, "episodes": [ good(1), bad, good(2) ] }); @@ -731,7 +768,7 @@ async fn bundle_envelope_errors_are_whole_request_400s() { .post_json_auth( "/api/v1/manifests/bundle", &token, - &json!({ "jmanifest_version": 1, "series": {}, "episodes": [] }), + &json!({ "jmanifest_version": 2, "series": {}, "episodes": [] }), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST, "series needs an identifier"); @@ -740,7 +777,7 @@ async fn bundle_envelope_errors_are_whole_request_400s() { .post_json_auth( "/api/v1/manifests/bundle", &token, - &json!({ "jmanifest_version": 1, + &json!({ "jmanifest_version": 2, "series": { "series_tmdb_id": "1396" }, "episodes": [], "extra": 1 }), ) @@ -754,13 +791,13 @@ async fn bundle_rejects_an_episode_contradicting_the_envelope() { let s = TestServer::new("bundle-mismatch"); let token = s.token().await; let bundle = json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "series": { "series_tmdb_id": "1396" }, "episodes": [ { - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "9999", "season": 1, "episode": 1 }, "cut": { "runtime_sec": 2820.0 }, - "actors": [ { "tmdb_id": "17419", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ] } ] }); let (status, body, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await; @@ -776,16 +813,16 @@ async fn bundle_beyond_the_episode_cap_is_413() { let episodes: Vec = (0..501) .map(|i| { json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": i }, "cut": { "runtime_sec": 2820.0 }, - "actors": [ { "tmdb_id": "17419", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ] }) }) .collect(); let bundle = json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "series": { "series_tmdb_id": "1396" }, "episodes": episodes }); @@ -805,7 +842,7 @@ async fn bundle_route_accepts_a_body_larger_than_the_single_manifest_cap() { .map(|ep| { let scenes: Vec = (0..600).map(|j| json!([j as f64, (j + 1) as f64])).collect(); json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": ep }, "cut": { "runtime_sec": 2820.0 }, @@ -816,7 +853,7 @@ async fn bundle_route_accepts_a_body_larger_than_the_single_manifest_cap() { }) .collect(); let bundle = json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "series": { "series_tmdb_id": "1396" }, "episodes": episodes }); diff --git a/tests/injection.rs b/tests/injection.rs index fc248ad..68360d6 100644 --- a/tests/injection.rs +++ b/tests/injection.rs @@ -261,10 +261,10 @@ async fn sql_payloads_in_identifier_fields_are_rejected() { for payload in SQL_PAYLOADS { let manifest = json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": payload }, "cut": { "runtime_sec": 100.0 }, - "actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ] }); let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await; assert_eq!( @@ -288,16 +288,16 @@ async fn sql_payloads_in_free_text_fields_are_rejected() { for payload in SQL_PAYLOADS { for manifest in [ json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": "504172", "title": payload }, "cut": { "runtime_sec": 100.0 }, - "actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ] }), json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": "504172" }, "cut": { "runtime_sec": 100.0 }, - "actors": [ { "name": payload, "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "name": payload, "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ] }), ] { let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await; @@ -326,10 +326,10 @@ async fn sql_payloads_in_a_report_note_cannot_escape() { "/api/v1/manifests", Some(&token), &json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": "504172" }, "cut": { "runtime_sec": 100.0 }, - "actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ] }), ) .await; @@ -375,10 +375,10 @@ async fn sql_payloads_in_a_bearer_token_are_inert() { .header("authorization", format!("Bearer {payload}")) .body(Body::from( json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": "504172" }, "cut": { "runtime_sec": 100.0 }, - "actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ] }) .to_string(), )) @@ -422,16 +422,16 @@ async fn json_structure_abuse_is_rejected_cleanly() { let cases: Vec<(&str, String)> = vec![ ("deep nesting", format!("{}{}", "[".repeat(20_000), "]".repeat(20_000))), ("unterminated", "{\"identity\": {\"type\": \"movie\"".to_string()), - ("duplicate keys", r#"{"jmanifest_version":1,"jmanifest_version":2}"#.to_string()), + ("duplicate keys", r#"{"jmanifest_version":2,"jmanifest_version":2}"#.to_string()), ("null bytes", "{\"jmanifest_version\":\u{0}1}".to_string()), ("huge number", format!("{{\"jmanifest_version\":{}}}", "9".repeat(5000))), - ("nan literal", r#"{"jmanifest_version":1,"cut":{"runtime_sec":NaN}}"#.to_string()), + ("nan literal", r#"{"jmanifest_version":2,"cut":{"runtime_sec":NaN}}"#.to_string()), ("bare array", "[1,2,3]".to_string()), ("bare string", "\"just a string\"".to_string()), ("empty body", String::new()), ( "prototype-style key", - r#"{"__proto__":{"admin":true},"jmanifest_version":1}"#.to_string(), + r#"{"__proto__":{"admin":true},"jmanifest_version":2}"#.to_string(), ), ]; @@ -463,7 +463,7 @@ async fn non_finite_scene_times_are_rejected() { // Sent as raw JSON text rather than via `json!`, because rustc refuses an // out-of-range float literal — and the point is to make the *server's* parser // handle it, which is the real attack path. - let raw = r#"{"jmanifest_version":1, + let raw = r#"{"jmanifest_version":2, "identity":{"type":"movie","tmdb_id":"504172"}, "cut":{"runtime_sec":100.0}, "actors":[{"tmdb_id":"884","scenes":[[1.0,1e400]]}]}"#; @@ -479,10 +479,10 @@ async fn non_finite_scene_times_are_rejected() { assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); // Likewise an overflowing runtime. - let raw = r#"{"jmanifest_version":1, + let raw = r#"{"jmanifest_version":2, "identity":{"type":"movie","tmdb_id":"504172"}, "cut":{"runtime_sec":1e400}, - "actors":[{"tmdb_id":"884","scenes":[[1.0,2.0]]}]}"#; + "actors":[{"tmdb_id":"884","scenes":[{"start":1.0,"end":2.0}]}]}"#; let req = Request::builder() .method("POST") .uri("/api/v1/manifests") @@ -583,10 +583,10 @@ async fn unicode_tricks_cannot_smuggle_text_past_the_character_class() { "/api/v1/manifests", Some(&token), &json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": "504172" }, "cut": { "runtime_sec": 100.0 }, - "actors": [ { "name": payload, "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "name": payload, "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ] }), ) .await; @@ -617,10 +617,10 @@ async fn the_audio_signature_field_cannot_carry_arbitrary_bytes() { "/api/v1/manifests", Some(&token), &json!({ - "jmanifest_version": 1, + "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": "504172" }, "cut": { "runtime_sec": 6420.5, "audio_signature": sig }, - "actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ] + "actors": [ { "tmdb_id": "884", "scenes": [{"start":1.0,"end":2.0}] } ] }), ) .await;