Ship the SR-003 schema bump: jmanifest_version 2
Moves the exchange envelope to version 2 in lockstep with the truth file's schema_version, per SR-003's requirement that breaking changes be batched and ship together rather than piecemeal. The plugin had already moved to schema_version 2; the server declaring 1 while accepting the new fields defeated the point of having a version at all. Flag day, not dual-accept (JR-003): version 1 is now 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. A pipeline still emitting v1 is incompatible until updated — stated plainly rather than papered over with a shim nobody tests. scenes become objects carrying belief and route (extraction AR-017) instead of float pairs. Belief is bounded to [0, 1] rather than merely stored: §5a's Threat 1 argument rests on every accepted value being bounded, and an unbounded float is a 64-bit channel however harmless it looks. route is a closed enum, so an invented value cannot be stored. UR-018 is the requirement with the trap in it, and the reason content_id.rs is untouched by this commit: 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 ids for the same content — the exact failure mode §9a quantises centiseconds to avoid, reintroduced one field along. It replicates as an attribute, exactly as audio_signature does. The golden vector still passes unchanged, which is the evidence rather than the claim. 191 tests. UR-015..018 move from Planned to Done; coverage 24/32 (75%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: UR-014, UR-015, UR-016, UR-017, UR-018 | SR-003
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+23
-16
@@ -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
|
||||
|
||||
|
||||
+50
-39
@@ -3,7 +3,7 @@
|
||||
<!-- GENERATED FILE - do not edit by hand. -->
|
||||
<!-- Regenerate: scripts/traceability/traceability-gate.sh -->
|
||||
|
||||
**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<Vec<Jo…`
|
||||
- [`src/db/repo.rs:709`](../src/db/repo.rs#L709) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
|
||||
|
||||
### DR-006
|
||||
|
||||
@@ -151,7 +151,7 @@ _None._
|
||||
|
||||
- [`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/validate.rs:78`](../src/validate.rs#L78) — `pub fn to_centiseconds(secs: f64) -> 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<Vec<Jo…`
|
||||
- [`src/db/repo.rs:709`](../src/db/repo.rs#L709) — `pub fn lease_jobs(tx: &Transaction<'_>, now: &str, limit: usize) -> anyhow::Result<Vec<Jo…`
|
||||
|
||||
### PR-005
|
||||
|
||||
@@ -183,7 +183,7 @@ _None._
|
||||
- [`src/api/upload.rs:29`](../src/api/upload.rs#L29) — `pub async fn post_manifest(`
|
||||
- [`src/api/upload.rs:101`](../src/api/upload.rs#L101) — `pub async fn post_bundle(`
|
||||
- [`src/ingest.rs:50`](../src/ingest.rs#L50) — `pub fn persist(`
|
||||
- [`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<()>`
|
||||
|
||||
### 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<CutMatch>`
|
||||
- [`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<Vec<(i64, i64)>>`
|
||||
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
|
||||
- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
|
||||
|
||||
### 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<ValidManifest>`
|
||||
- [`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<Self>`
|
||||
- [`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<ValidManifest>`
|
||||
- [`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<ValidManifest>`
|
||||
- [`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<ValidManifest>`
|
||||
- [`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<ValidManifest>`
|
||||
- [`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<ValidManifest>`
|
||||
- [`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<Vec<(i64, i64)>>`
|
||||
- [`src/model.rs:233`](../src/model.rs#L233) — `pub fn from_str(s: &str) -> Option<Self>`
|
||||
- [`src/validate.rs:510`](../src/validate.rs#L510) — `fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>>`
|
||||
|
||||
### 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<ValidManifest>`
|
||||
- [`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<ValidManifest>`
|
||||
|
||||
### 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<Self>`
|
||||
|
||||
|
||||
+7
-2
@@ -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();
|
||||
|
||||
+38
-11
@@ -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<String>,
|
||||
pub scenes_cs: Vec<(i64, i64)>,
|
||||
pub scenes_cs: Vec<crate::validate::SceneCs>,
|
||||
}
|
||||
|
||||
/// TRACES: UR-010 | DR-002 | SR-001
|
||||
@@ -421,7 +428,7 @@ pub fn actors_for_manifest(
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
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<String>>(3)?
|
||||
.and_then(|v| crate::model::Route::from_stored(&v)),
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
out.push(StoredActor { tmdb_person_id: id, name, scenes_cs });
|
||||
}
|
||||
@@ -742,6 +760,7 @@ pub fn release_all_leases(tx: &Transaction<'_>) -> anyhow::Result<usize> {
|
||||
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 =
|
||||
|
||||
+7
-1
@@ -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 (
|
||||
|
||||
+72
-18
@@ -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));
|
||||
}
|
||||
|
||||
+92
-9
@@ -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<String>,
|
||||
/// `[start_sec, end_sec]` inclusive, sorted.
|
||||
pub scenes: Vec<[f64; 2]>,
|
||||
/// Presence windows, inclusive and sorted by start.
|
||||
pub scenes: Vec<Scene>,
|
||||
}
|
||||
|
||||
/// 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<Self> {
|
||||
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<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub route: Option<Route>,
|
||||
}
|
||||
|
||||
/// 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::<Jmanifest>(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::<Jmanifest>(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::<Jmanifest>(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::<Jmanifest>(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);
|
||||
|
||||
+85
-28
@@ -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<ActorScenes>,
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
pub route: Option<Route>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub tmdb_id: Option<u64>,
|
||||
pub imdb_id: Option<String>,
|
||||
pub scenes_cs: Vec<(i64, i64)>,
|
||||
pub scenes_cs: Vec<SceneCs>,
|
||||
}
|
||||
|
||||
/// Quantises seconds to whole centiseconds (§9a).
|
||||
@@ -484,7 +508,7 @@ fn validate_actors(m: &Jmanifest) -> VResult<Vec<ActorScenes>> {
|
||||
}
|
||||
|
||||
/// TRACES: UR-013 | SR-002
|
||||
fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64, i64)>> {
|
||||
fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<SceneCs>> {
|
||||
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<Vec<(i64,
|
||||
let max_t = runtime_sec + limits::RUNTIME_TOLERANCE_SEC;
|
||||
let mut out = Vec::with_capacity(a.scenes.len());
|
||||
|
||||
for (j, [start, end]) in a.scenes.iter().copied().enumerate() {
|
||||
for (j, scene) in a.scenes.iter().copied().enumerate() {
|
||||
let field = format!("actors[{idx}].scenes[{j}]");
|
||||
let (start, end) = (scene.start, scene.end);
|
||||
// §6: non-finite values (NaN/Infinity), negative times, `end < start`,
|
||||
// or times beyond `runtime_sec` + tolerance.
|
||||
if !start.is_finite() || !end.is_finite() {
|
||||
@@ -516,12 +541,33 @@ fn validate_scenes(idx: usize, a: &Actor, runtime_sec: f64) -> VResult<Vec<(i64,
|
||||
),
|
||||
));
|
||||
}
|
||||
out.push((to_centiseconds(start), to_centiseconds(end)));
|
||||
|
||||
// A posterior outside scene(0, 1) is not a probability. Bounded here rather
|
||||
// than merely stored, because §5a's Threat 1 argument rests on every
|
||||
// accepted value being a *bounded* number — an unbounded float is a
|
||||
// 64-bit channel, however harmless it looks.
|
||||
if let Some(belief) = scene.belief {
|
||||
if !belief.is_finite() || !(0.0..=1.0).contains(&belief) {
|
||||
return Err(err(
|
||||
format!("actors[{idx}].scenes[{j}].belief"),
|
||||
"must be a finite probability in scene(0, 1)",
|
||||
));
|
||||
}
|
||||
}
|
||||
// `route` is a closed enum, so an unrecognised value is already a parse
|
||||
// error — nothing to check here.
|
||||
|
||||
out.push(SceneCs {
|
||||
start_cs: to_centiseconds(start),
|
||||
end_cs: to_centiseconds(end),
|
||||
belief: scene.belief,
|
||||
route: scene.route,
|
||||
});
|
||||
}
|
||||
|
||||
// §2: scenes are sorted. Checked on the quantised values so the stored form
|
||||
// is the one guaranteed ordered.
|
||||
if out.windows(2).any(|w| w[1].0 < w[0].0) {
|
||||
if out.windows(2).any(|w| w[1].start_cs < w[0].start_cs) {
|
||||
return Err(err(format!("actors[{idx}].scenes"), "windows must be sorted by start time"));
|
||||
}
|
||||
Ok(out)
|
||||
@@ -641,13 +687,19 @@ fn base64_decode(s: &str) -> Result<Vec<u8>, &'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());
|
||||
|
||||
|
||||
+57
-20
@@ -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<Value> = (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<Value> = (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
|
||||
});
|
||||
|
||||
+20
-20
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user