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:
+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);
|
||||
|
||||
Reference in New Issue
Block a user