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
+85 -28
View File
@@ -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());