Ship the SR-003 schema bump: jmanifest_version 2
CI / fmt, clippy, test (push) Failing after 1m22s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 26s

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:
2026-07-31 09:13:14 +02:00
co-authored by Claude Opus 5
parent c73f417d45
commit 88c7264094
12 changed files with 494 additions and 179 deletions
+92 -9
View File
@@ -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);