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