JR-004, JR-005, JR-006: scene-scoped read path

Presence was decided by a LINQ predicate inline in the controller, so the
semantics SR-002 sets were nowhere stated in code -- the read path complied by
accident rather than by requirement. PresenceLookup is now the unit that
decides, tagged, with the reasoning next to it.

JR-004: windows are served exactly as given. UT-021 pins that [0,10] and
[10,20] are not merged despite looking mergeable -- two windows mean a genuine
departure and return, and collapsing them answers a different question from the
one the truth file asked. UT-022 pins a byte-identical round trip.

JR-005: bounds inclusive at both ends, zero-length windows are real sightings
rather than degenerate ones to discard, overlaps resolve.

The wording was the larger half of JR-005. The overlay rendered a bare list: it
asserted nothing, but told the viewer nothing either, and the default reading of
a paused frame is "these people are on screen" -- exactly what SR-002 forbids.
It now carries an "In this scene" heading. ActorAtTime became ActorInScene, and
README no longer contains "on screen" anywhere; it stated the forbidden reading
outright in seven places, including the opening sentence.

JR-006: measured rather than assumed. UT-023 builds 50 actors x 1000 windows and
asserts the response is bounded by actor count, never window count. The lookup
is a full scan on purpose -- an early exit on `start > t` would exploit the
sortedness the format requires, but would silently under-report the moment one
producer emitted windows out of order. UT-020 pins that unsorted input still
resolves; WindowsAreSorted is a diagnostic, not a correctness dependency.

Third mutation check: making the end bound exclusive fails UT-016 and UT-018 and
nothing else. One character turns an inclusive window into a half-open one,
dropping an actor at exactly the moment a scene ends.

TRACES: UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023
TRACES: JR-004, JR-005, JR-006 | SR-002

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:29:46 +02:00
co-authored by Claude Opus 5
parent 305b898b15
commit c04d5a3dcc
11 changed files with 378 additions and 47 deletions
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Answers "which actors are in the scene at time <c>t</c>" from a truth file.
/// </summary>
/// <remarks>
/// This is the unit that decides presence, so the scene-scoped semantics live
/// here rather than being spread through the controller.
///
/// <para>
/// <b>A window is a claim about scene membership, not a recognition event.</b>
/// An actor who turns away, is occluded, or is off-camera while the shot cuts to
/// whoever they are speaking to is still present. Two windows mean a genuine
/// departure and return, not a break in detection — so this code reads windows
/// exactly as given and never merges, splits, trims, or reorders them.
/// </para>
/// <para>
/// Bounds are inclusive at both ends, matching the format's definition. That
/// makes adjacent windows such as <c>[0,10]</c> and <c>[10,20]</c> both contain
/// <c>t = 10</c>; reporting the actor present once is correct, and is not a
/// reason to merge the windows.
/// </para>
/// </remarks>
// TRACES: JR-004, JR-005, JR-006 | SR-002
public static class PresenceLookup
{
/// <summary>
/// Determines whether an actor is present in the scene at <paramref name="t"/>.
/// </summary>
/// <param name="actor">The actor entry from a truth file.</param>
/// <param name="t">The timestamp, in seconds.</param>
/// <returns><c>true</c> when any window contains <paramref name="t"/>.</returns>
public static bool IsPresentAt(TruthActor actor, double t)
{
if (actor is null)
{
return false;
}
// A full scan, deliberately: windows may be numerous, but correctness
// must not depend on the producer having honoured the sortedness
// guarantee. An early exit on `start > t` would be faster and would
// silently under-report the moment one file arrived out of order —
// trading a correctness risk for a saving that does not matter at this
// scale (see JR-006).
foreach (var window in actor.Scenes)
{
if (window.Length == 2 && window[0] <= t && t <= window[1])
{
return true;
}
}
return false;
}
/// <summary>
/// Lists the actors present in the scene at <paramref name="t"/>, in the
/// order the truth file lists them.
/// </summary>
/// <param name="truth">The truth file.</param>
/// <param name="t">The timestamp, in seconds.</param>
/// <returns>The actors whose windows contain <paramref name="t"/>.</returns>
public static IEnumerable<TruthActor> ActorsPresentAt(TruthFile truth, double t)
{
if (truth is null)
{
yield break;
}
foreach (var actor in truth.Actors)
{
if (IsPresentAt(actor, t))
{
yield return actor;
}
}
}
/// <summary>
/// Determines whether an actor's windows are sorted by start time, as the
/// truth-file format requires of producers.
/// </summary>
/// <remarks>
/// Presence lookup does not depend on this — it is a diagnostic. A file that
/// fails it is still read correctly, but it signals a producer bug worth
/// surfacing rather than absorbing silently.
/// </remarks>
/// <param name="actor">The actor entry from a truth file.</param>
/// <returns><c>true</c> when every window starts at or after its predecessor.</returns>
public static bool WindowsAreSorted(TruthActor actor)
{
if (actor is null)
{
return true;
}
double previousStart = double.NegativeInfinity;
foreach (var window in actor.Scenes)
{
if (window.Length != 2)
{
continue;
}
if (window[0] < previousStart)
{
return false;
}
previousStart = window[0];
}
return true;
}
}