diff --git a/src/build_gallery.cpp b/src/build_gallery.cpp index 38b84ff..e30e1f6 100644 --- a/src/build_gallery.cpp +++ b/src/build_gallery.cpp @@ -18,6 +18,8 @@ // --nms NMS IoU threshold (default: 0.4) #include "gallery/gallery_builder.hpp" +#include "gallery/gallery_report.hpp" +#include "gallery/gallery_calibration.hpp" #include "gallery/gallery_store.hpp" #include "config.hpp" @@ -77,6 +79,30 @@ int main(int argc, char** argv) { } save_gallery(output_path, gallery); std::cerr << "Gallery saved to: " << output_path << "\n"; + + /// TRACES: GR-003 | SR-001 + // Fit the calibration here and persist what it learned. The matcher + // fits the same sigmoid at analysis time, but that is the wrong place + // to audit a gallery from: by then the answer is per-run and nobody is + // looking. Build time is when the gallery's quality is decided, and a + // gallery can be quietly bad — heavily overlapping intra/inter + // distributions, actors with no usable image — while looking fine. + std::vector flat; + std::vector flat_actor; + for (int ai = 0; ai < static_cast(gallery.actors.size()); ++ai) + for (const auto& e : gallery.actors[ai].embeddings) { + flat.push_back(e); + flat_actor.push_back(ai); + } + + GalleryCalibrationStats stats; + GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats); + + const GalleryReport report = + build_gallery_report(gallery, cal, stats, nullptr, output_path); + const std::string report_path = gallery_report_path(output_path); + save_gallery_report(report_path, report); + std::cerr << "Gallery report saved to: " << report_path << "\n"; } catch (const std::exception& e) { std::cerr << "Fatal: " << e.what() << "\n"; return 1; diff --git a/src/gallery/gallery_builder.hpp b/src/gallery/gallery_builder.hpp index d8c3357..96650c4 100644 --- a/src/gallery/gallery_builder.hpp +++ b/src/gallery/gallery_builder.hpp @@ -1,4 +1,5 @@ #pragma once +#include "gallery/gallery_report.hpp" #include "types.hpp" #include diff --git a/src/gallery/gallery_calibration.hpp b/src/gallery/gallery_calibration.hpp index 3788284..f67f4f8 100644 --- a/src/gallery/gallery_calibration.hpp +++ b/src/gallery/gallery_calibration.hpp @@ -77,14 +77,48 @@ inline std::function same_person_probability(const GalleryCalibrat return [cal](float similarity) { return cal.probability(similarity); }; } +/// TRACES: GR-003 | SR-001 +/// +/// Everything the fit learns about the gallery on its way to two numbers. +/// +/// The fit computes per-actor dedup counts, which actors can supply positive +/// pairs at all, and the two similarity distributions the sigmoid is derived +/// from — and then returns only (a, b, valid). GR-003 exists because that is the +/// evidence for whether the calibration, and so every threshold expressed in its +/// probability space (AR-024), rests on anything. Filling this struct costs +/// nothing: the values already exist at the point they are copied out. +/// +/// Per-actor vectors are indexed by the actor index used in `flat_actor`. +struct GalleryCalibrationStats { + int n_actors = 0; + int min_embeddings_for_positive = 0; + float dedup_sim_threshold = 0.f; + + std::vector distinct_per_actor; // after near-duplicate removal + std::vector duplicates_removed_per_actor; + std::vector eligible; // 1 = supplies positive pairs + + int hist_bins = 0; // over sim ∈ [-1, 1] + std::vector intra_hist; + std::vector inter_hist; + double n_intra_pairs = 0.0; + double n_inter_pairs = 0.0; + + double train_accuracy_pct = 0.0; +}; + // Fit a logistic sigmoid to gallery pair similarities. // Positive pairs: same actor, different reference images. // Negative pairs: different actors (all cross-actor embedding pairs). // Class weights balance the (typically skewed) pos/neg ratio. // Requires ≥2 positive pairs and ≥1 negative pair. +// +// `stats` is optional (GR-003): pass one to receive the dedup, eligibility and +// distribution detail the fit would otherwise discard. inline GalleryCalibration calibrate_gallery( const std::vector& flat_emb, - const std::vector& flat_actor) + const std::vector& flat_actor, + GalleryCalibrationStats* stats = nullptr) { constexpr int kMinEmbeddingsForPositive = 5; constexpr float kDedupSimThreshold = 1.f - 1e-7f; // sim above this = duplicate @@ -108,6 +142,21 @@ inline GalleryCalibration calibrate_gallery( std::vector actor_eligible(n_actors, false); int n_eligible = 0; + /// TRACES: GR-003 | SR-001 + // Record what the filter did, per actor, while the counts still exist. + if (stats) { + *stats = GalleryCalibrationStats{}; + stats->n_actors = n_actors; + stats->min_embeddings_for_positive = kMinEmbeddingsForPositive; + stats->dedup_sim_threshold = kDedupSimThreshold; + stats->distinct_per_actor.assign(n_actors, 0); + stats->duplicates_removed_per_actor.assign(n_actors, 0); + stats->eligible.assign(n_actors, 0); + stats->hist_bins = kHistBins; + stats->intra_hist.assign(kHistBins, 0.0); + stats->inter_hist.assign(kHistBins, 0.0); + } + for (int ai = 0; ai < n_actors; ++ai) { std::vector kept; for (const auto& e : by_actor[ai]) { @@ -121,6 +170,12 @@ inline GalleryCalibration calibrate_gallery( actor_eligible[ai] = true; ++n_eligible; } + if (stats) { + stats->distinct_per_actor[ai] = static_cast(kept.size()); + stats->duplicates_removed_per_actor[ai] = + static_cast(by_actor[ai].size() - kept.size()); + stats->eligible[ai] = actor_eligible[ai] ? 1 : 0; + } for (auto& e : kept) { flat_emb_dedup.push_back(e); flat_actor_dedup.push_back(ai); @@ -128,6 +183,14 @@ inline GalleryCalibration calibrate_gallery( } const int n = static_cast(flat_emb_dedup.size()); + + // Nothing to fit and nothing to multiply. Returning here keeps the report + // buildable for a degenerate gallery instead of handing cv::gemm an empty + // matrix; the per-actor stats above are already filled and still useful. + if (n == 0) { + std::cerr << "[calibration] no embeddings — calibration skipped\n"; + return {}; + } std::cerr << "[calibration] dedup: " << flat_emb.size() << " -> " << n << " embeddings (" << n_eligible << "/" << n_actors << " actors have >= " << kMinEmbeddingsForPositive @@ -228,6 +291,17 @@ inline GalleryCalibration calibrate_gallery( double n_pos = 0.0, n_neg = 0.0; for (int b = 0; b < kHistBins; ++b) { n_pos += pos_hist[b]; n_neg += neg_hist[b]; } + /// TRACES: GR-003 | SR-001 + // The two distributions the sigmoid is about to be fitted from. Emitted + // whether or not the fit succeeds — a failed fit is exactly the case where + // someone needs to see why. + if (stats) { + stats->intra_hist = pos_hist; + stats->inter_hist = neg_hist; + stats->n_intra_pairs = n_pos; + stats->n_inter_pairs = n_neg; + } + if (n_pos < 2 || n_neg < 1) { std::cerr << "[calibration] insufficient pairs (+" << n_pos << "/-" << n_neg << ") — calibration skipped\n"; @@ -282,6 +356,7 @@ inline GalleryCalibration calibrate_gallery( correct += (sig > 0.5f) ? pos_hist[b] : neg_hist[b]; } double acc = 100.0 * correct / total; + if (stats) stats->train_accuracy_pct = acc; GalleryCalibration cal{a, bias, true}; std::cerr << "[calibration] sigmoid fitted:" diff --git a/src/gallery/gallery_report.hpp b/src/gallery/gallery_report.hpp new file mode 100644 index 0000000..fa053db --- /dev/null +++ b/src/gallery/gallery_report.hpp @@ -0,0 +1,495 @@ +#pragma once +/// TRACES: GR-003 | SR-001 +/// +/// The gallery build report — what the gallery *is*, written next to it. +/// +/// A gallery is a silent artefact: it loads, it scores, it never complains. The +/// two ways it fails are both invisible from the outside. +/// +/// 1. **An actor with zero usable images can never be recognised.** They are +/// dropped at build time (`gallery_builder.cpp` skips a directory whose +/// images all fail detection or alignment), so afterwards nothing in the +/// file records that they were ever meant to be there. Every scene they +/// appear in is a guaranteed miss, and recall is capped at a number nobody +/// computed. This is the single most useful line in the report. +/// 2. **A gallery can be quietly bad and look fine.** The Platt sigmoid +/// (AR-023) is fitted from two distributions — intra-class (same actor, +/// different reference) and inter-class (different actors) similarity — and +/// *every* threshold in the pipeline is expressed in the probability space +/// that fit defines (AR-024): identity acceptance, track association, +/// expansion admission, cluster merging. If those two distributions overlap +/// heavily the fit is weak, and every downstream decision silently inherits +/// that weakness while still reporting confident-looking probabilities. The +/// fit already computes the distributions and throws them away; emitting +/// them is what makes the quality of the whole probability space auditable +/// instead of assumed. +/// +/// The report is therefore a build artefact, not a debug aid: it is the only +/// place the recall ceiling and the calibration's conditioning are written down. +/// +/// **On the histograms being in cosine space.** They bin raw similarity, and +/// that is not an AR-024 violation: no decision is taken here. These two +/// distributions are the *input* the calibration is fitted from — they cannot be +/// expressed in the probability space the calibration defines, because that +/// space is their output. GR-003 asks for exactly this ("the intra/inter +/// distributions behind it"), for the same reason GR-008 characterises an +/// actor's reference spread in the metric space: shape is a property of the +/// metric, decisions are a property of the probability. +#include "gallery/gallery_calibration.hpp" +#include "gallery/embedder_stamp.hpp" +#include "types.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +/// TRACES: GR-003 | SR-001 +/// +/// Per-actor image accounting from the build pass, including the actors that +/// produced nothing and were therefore dropped from the gallery. +/// +/// Filled by `build_gallery()`. It has to be collected there and cannot be +/// recovered later: by the time a gallery exists, an actor with no usable image +/// is indistinguishable from an actor who was never requested. +struct GalleryBuildAudit { + struct ActorImages { + std::string imdb_id; + std::string name; + int images_seen = 0; // candidate image files in the actor's directory + int images_used = 0; // ...that yielded an embedding + int unreadable = 0; // cv::imread failed + int no_face = 0; // detector found nothing + int align_failed = 0; // 5-point warp failed + }; + std::vector actors; // every directory seen, in build order +}; + +/// TRACES: GR-003 | SR-001 +struct GalleryReport { + // One row per actor the build considered. Actors with references == 0 are + // the zero-usable-image case: present in the source tree, absent from the + // gallery, unrecognisable for the life of the file. + struct Actor { + std::string imdb_id; + std::string name; + int images_seen = -1; // -1 = unknown (report built without a build audit) + int references = 0; // embeddings stored in the gallery + int distinct_references = 0; // ...after near-duplicate removal + int duplicates_removed = 0; + bool eligible_for_positive_pairs = false; + }; + + // The two distributions the sigmoid is fitted from, as the fit itself saw + // them: counts per similarity bin over [sim_min, sim_max]. + struct Distributions { + int bins = 0; + float sim_min = -1.f; + float sim_max = 1.f; + std::vector intra; // same actor, different reference image + std::vector inter; // different actors + double intra_pairs = 0.0; + double inter_pairs = 0.0; + double intra_mean = 0.0; + double inter_mean = 0.0; + // Normalised histogram intersection, Σ_b min(p_intra[b], p_inter[b]). + // 0 = perfectly separated, 1 = indistinguishable. This is the number + // that says whether the calibration — and so every threshold expressed + // in its probability space — rests on anything. + double overlap = 0.0; + }; + + // GR-003 / AR-023 open question, reported but NOT applied. The spec asks for + // a gallery-derived prior of intra/(intra+inter); the shipped default is + // 0.5. Persisting the distributions makes the real value computable, so the + // decision can be taken on evidence rather than left implicit. Behaviour is + // unchanged: `applied` is always false here. + struct Prior { + double derived = 0.0; // intra_pairs / (intra_pairs + inter_pairs) + double derived_log_odds = 0.0; // log(p/(1-p)), the term AR-023 would add + float configured_default = 0.5f; + bool applied = false; + std::string note; + }; + + std::string schema{"sae.gallery_report/1"}; + std::string gallery_path; + EmbedderStamp embedder; + + // ── Summary ────────────────────────────────────────────────────────────── + int actors_total = 0; // considered (gallery + zero-usable) + int actors_in_gallery = 0; + int actors_zero_usable = 0; + int actors_below_positive_threshold = 0; + int64_t embeddings_total = 0; + int64_t distinct_embeddings_total = 0; + int64_t duplicates_removed_total = 0; + double mean_embeddings_per_actor = 0.0; // over actors in the gallery + int min_embeddings_for_positive_pairs = 0; + float dedup_similarity_threshold = 0.f; + + // ── Calibration ────────────────────────────────────────────────────────── + float calib_a = 10.f; + float calib_b = -5.f; + bool calib_valid = false; + uint64_t calib_hash = 0; + double calib_train_accuracy_pct = 0.0; + float calib_boundary_p50 = 0.f; // similarity at which P(match) = 0.5 + + Distributions distributions; + Prior prior; + + std::vector actors; + // Names duplicated out of `actors` so the two failure modes are greppable + // without a JSON query. These are the lines a human reads first. + std::vector zero_usable; + std::vector below_positive_threshold; +}; + +/// TRACES: GR-003 | SR-001 +/// +/// Assembles the report from the three things that know a piece of the answer: +/// the gallery itself (who is in it, with how many references), the calibration +/// stats (dedup, eligibility, the two distributions), and the build audit (who +/// was considered and produced nothing). The audit is optional — a report built +/// from a stored gallery simply cannot know about the actors that never made it. +/// +/// `stats` is indexed by actor index, so the flat arrays handed to +/// `calibrate_gallery()` must have used the gallery's own actor ordering. +inline GalleryReport build_gallery_report(const ActorGallery& gallery, + const GalleryCalibration& cal, + const GalleryCalibrationStats& stats, + const GalleryBuildAudit* audit = nullptr, + const std::string& gallery_path = "", + float configured_prior = 0.5f) +{ + GalleryReport r; + r.gallery_path = gallery_path; + r.embedder = gallery.embedder; + + r.calib_a = cal.a; + r.calib_b = cal.b; + r.calib_valid = cal.valid; + r.calib_hash = gallery.calib_hash; + r.calib_train_accuracy_pct = stats.train_accuracy_pct; + r.calib_boundary_p50 = cal.boundary_at(0.5f); + + r.min_embeddings_for_positive_pairs = stats.min_embeddings_for_positive; + r.dedup_similarity_threshold = stats.dedup_sim_threshold; + + auto audit_for = [&](const ActorGallery::Actor& a) -> const GalleryBuildAudit::ActorImages* { + if (!audit) return nullptr; + for (const auto& e : audit->actors) { + if (!a.imdb_id.empty() && e.imdb_id == a.imdb_id) return &e; + if (a.imdb_id.empty() && e.name == a.name) return &e; + } + return nullptr; + }; + + for (size_t i = 0; i < gallery.actors.size(); ++i) { + const auto& ga = gallery.actors[i]; + GalleryReport::Actor row; + row.imdb_id = ga.imdb_id; + row.name = ga.name; + row.references = static_cast(ga.embeddings.size()); + if (const auto* au = audit_for(ga)) row.images_seen = au->images_seen; + + if (i < stats.distinct_per_actor.size()) { + row.distinct_references = stats.distinct_per_actor[i]; + row.duplicates_removed = stats.duplicates_removed_per_actor[i]; + row.eligible_for_positive_pairs = stats.eligible[i] != 0; + } else { + // No calibration stats for this actor (the fit never saw them). + // Report the raw count rather than a fabricated distinct count. + row.distinct_references = row.references; + } + + r.embeddings_total += row.references; + r.distinct_embeddings_total += row.distinct_references; + r.duplicates_removed_total += row.duplicates_removed; + if (!row.eligible_for_positive_pairs) { + ++r.actors_below_positive_threshold; + r.below_positive_threshold.push_back(row.name); + } + r.actors.push_back(std::move(row)); + } + r.actors_in_gallery = static_cast(gallery.actors.size()); + + // Actors the build considered and could not use at all. They are not in the + // gallery, so this is the only record that they exist. + if (audit) { + for (const auto& e : audit->actors) { + if (e.images_used > 0) continue; + GalleryReport::Actor row; + row.imdb_id = e.imdb_id; + row.name = e.name; + row.images_seen = e.images_seen; + row.references = 0; + r.zero_usable.push_back(e.name); + r.actors.push_back(std::move(row)); + } + } + r.actors_zero_usable = static_cast(r.zero_usable.size()); + r.actors_total = r.actors_in_gallery + r.actors_zero_usable; + r.mean_embeddings_per_actor = + r.actors_in_gallery > 0 + ? static_cast(r.embeddings_total) / r.actors_in_gallery + : 0.0; + + // ── The two distributions, straight out of the fit ─────────────────────── + auto& d = r.distributions; + d.bins = stats.hist_bins; + d.sim_min = -1.f; + d.sim_max = 1.f; + d.intra = stats.intra_hist; + d.inter = stats.inter_hist; + d.intra_pairs = stats.n_intra_pairs; + d.inter_pairs = stats.n_inter_pairs; + + if (d.bins > 0) { + const double bin_w = (d.sim_max - d.sim_min) / d.bins; + double si = 0.0, se = 0.0; + for (int b = 0; b < d.bins; ++b) { + const double centre = d.sim_min + (b + 0.5) * bin_w; + si += d.intra[b] * centre; + se += d.inter[b] * centre; + } + if (d.intra_pairs > 0.0) d.intra_mean = si / d.intra_pairs; + if (d.inter_pairs > 0.0) d.inter_mean = se / d.inter_pairs; + if (d.intra_pairs > 0.0 && d.inter_pairs > 0.0) { + double ov = 0.0; + for (int b = 0; b < d.bins; ++b) + ov += std::min(d.intra[b] / d.intra_pairs, d.inter[b] / d.inter_pairs); + d.overlap = ov; + } + } + + // ── The prior AR-023 leaves open — computed, reported, not applied ──────── + r.prior.configured_default = configured_prior; + r.prior.applied = false; + const double pair_total = d.intra_pairs + d.inter_pairs; + if (pair_total > 0.0) { + r.prior.derived = d.intra_pairs / pair_total; + const double p = std::clamp(r.prior.derived, 1e-12, 1.0 - 1e-12); + r.prior.derived_log_odds = std::log(p / (1.0 - p)); + } + r.prior.note = + "AR-023 specifies a gallery-derived prior of intra/(intra+inter); the shipped " + "match_prior default is 0.5 (calibrated sigmoid used directly). The derived value " + "is the base rate of same-actor pairs among ALL enumerated gallery pairs, so it " + "falls as the cast grows (roughly (k-1)/((k-1)+(A-1)k) for A actors with k " + "references each) — it is a property of gallery size as much as of the embedder. " + "Reported here as evidence; NOT applied. Behaviour is unchanged until the choice " + "is recorded in the spec."; + + return r; +} + +// ── JSON ───────────────────────────────────────────────────────────────────── + +/// TRACES: GR-003 | SR-001 +inline nlohmann::json gallery_report_to_json(const GalleryReport& r) { + nlohmann::json j; + j["schema"] = r.schema; + j["gallery_path"] = r.gallery_path; + j["embedder"] = {{"model_name", r.embedder.model_name}, + {"model_sha256", r.embedder.model_sha256}, + {"embed_dim", r.embedder.embed_dim}}; + + j["summary"] = { + {"actors_total", r.actors_total}, + {"actors_in_gallery", r.actors_in_gallery}, + {"actors_zero_usable", r.actors_zero_usable}, + {"actors_below_positive_threshold", r.actors_below_positive_threshold}, + {"embeddings_total", r.embeddings_total}, + {"distinct_embeddings_total", r.distinct_embeddings_total}, + {"duplicates_removed_total", r.duplicates_removed_total}, + {"mean_embeddings_per_actor", r.mean_embeddings_per_actor}, + {"min_embeddings_for_positive_pairs", r.min_embeddings_for_positive_pairs}, + {"dedup_similarity_threshold", r.dedup_similarity_threshold}}; + + j["calibration"] = { + {"a", r.calib_a}, + {"b", r.calib_b}, + {"valid", r.calib_valid}, + {"hash", r.calib_hash}, + {"train_accuracy_pct", r.calib_train_accuracy_pct}, + {"boundary_p50", r.calib_boundary_p50}}; + + const auto& d = r.distributions; + j["distributions"] = { + {"bins", d.bins}, + {"sim_min", d.sim_min}, + {"sim_max", d.sim_max}, + {"intra", d.intra}, + {"inter", d.inter}, + {"intra_pairs", d.intra_pairs}, + {"inter_pairs", d.inter_pairs}, + {"intra_mean", d.intra_mean}, + {"inter_mean", d.inter_mean}, + {"overlap", d.overlap}}; + + j["prior"] = { + {"derived", r.prior.derived}, + {"derived_log_odds", r.prior.derived_log_odds}, + {"configured_default", r.prior.configured_default}, + {"applied", r.prior.applied}, + {"note", r.prior.note}}; + + j["zero_usable"] = r.zero_usable; + j["below_positive_threshold"] = r.below_positive_threshold; + + j["actors"] = nlohmann::json::array(); + for (const auto& a : r.actors) { + j["actors"].push_back({ + {"imdb_id", a.imdb_id}, + {"name", a.name}, + {"images_seen", a.images_seen}, + {"references", a.references}, + {"distinct_references", a.distinct_references}, + {"duplicates_removed", a.duplicates_removed}, + {"eligible_for_positive_pairs", a.eligible_for_positive_pairs}}); + } + return j; +} + +/// TRACES: GR-003 | SR-001 +inline GalleryReport gallery_report_from_json(const nlohmann::json& j) { + GalleryReport r; + r.schema = j.value("schema", std::string{}); + r.gallery_path = j.value("gallery_path", std::string{}); + if (j.contains("embedder")) { + const auto& je = j.at("embedder"); + r.embedder.model_name = je.value("model_name", ""); + r.embedder.model_sha256 = je.value("model_sha256", ""); + r.embedder.embed_dim = je.value("embed_dim", 512); + } + if (j.contains("summary")) { + const auto& s = j.at("summary"); + r.actors_total = s.value("actors_total", 0); + r.actors_in_gallery = s.value("actors_in_gallery", 0); + r.actors_zero_usable = s.value("actors_zero_usable", 0); + r.actors_below_positive_threshold = s.value("actors_below_positive_threshold", 0); + r.embeddings_total = s.value("embeddings_total", int64_t{0}); + r.distinct_embeddings_total = s.value("distinct_embeddings_total", int64_t{0}); + r.duplicates_removed_total = s.value("duplicates_removed_total", int64_t{0}); + r.mean_embeddings_per_actor = s.value("mean_embeddings_per_actor", 0.0); + r.min_embeddings_for_positive_pairs = s.value("min_embeddings_for_positive_pairs", 0); + r.dedup_similarity_threshold = s.value("dedup_similarity_threshold", 0.f); + } + if (j.contains("calibration")) { + const auto& c = j.at("calibration"); + r.calib_a = c.value("a", 10.f); + r.calib_b = c.value("b", -5.f); + r.calib_valid = c.value("valid", false); + r.calib_hash = c.value("hash", uint64_t{0}); + r.calib_train_accuracy_pct = c.value("train_accuracy_pct", 0.0); + r.calib_boundary_p50 = c.value("boundary_p50", 0.f); + } + if (j.contains("distributions")) { + const auto& d = j.at("distributions"); + r.distributions.bins = d.value("bins", 0); + r.distributions.sim_min = d.value("sim_min", -1.f); + r.distributions.sim_max = d.value("sim_max", 1.f); + r.distributions.intra = d.value("intra", std::vector{}); + r.distributions.inter = d.value("inter", std::vector{}); + r.distributions.intra_pairs = d.value("intra_pairs", 0.0); + r.distributions.inter_pairs = d.value("inter_pairs", 0.0); + r.distributions.intra_mean = d.value("intra_mean", 0.0); + r.distributions.inter_mean = d.value("inter_mean", 0.0); + r.distributions.overlap = d.value("overlap", 0.0); + } + if (j.contains("prior")) { + const auto& p = j.at("prior"); + r.prior.derived = p.value("derived", 0.0); + r.prior.derived_log_odds = p.value("derived_log_odds", 0.0); + r.prior.configured_default = p.value("configured_default", 0.5f); + r.prior.applied = p.value("applied", false); + r.prior.note = p.value("note", ""); + } + r.zero_usable = j.value("zero_usable", std::vector{}); + r.below_positive_threshold = j.value("below_positive_threshold", std::vector{}); + if (j.contains("actors")) { + for (const auto& ja : j.at("actors")) { + GalleryReport::Actor a; + a.imdb_id = ja.value("imdb_id", ""); + a.name = ja.value("name", ""); + a.images_seen = ja.value("images_seen", -1); + a.references = ja.value("references", 0); + a.distinct_references = ja.value("distinct_references", 0); + a.duplicates_removed = ja.value("duplicates_removed", 0); + a.eligible_for_positive_pairs = ja.value("eligible_for_positive_pairs", false); + r.actors.push_back(std::move(a)); + } + } + return r; +} + +// "/cast.h5" → "/cast.report.json". A known gallery extension is +// replaced rather than appended to, so the report sits beside the gallery under +// the same stem. +inline std::string gallery_report_path(const std::string& gallery_path) { + auto slash = gallery_path.find_last_of("/\\"); + auto dot = gallery_path.find_last_of('.'); + std::string stem = + (dot != std::string::npos && (slash == std::string::npos || dot > slash)) + ? gallery_path.substr(0, dot) + : gallery_path; + return stem + ".report.json"; +} + +/// TRACES: GR-003 | SR-001 +inline void save_gallery_report(const std::string& path, const GalleryReport& r) { + std::ofstream out(path); + if (!out.is_open()) + throw std::runtime_error("save_gallery_report: cannot write " + path); + out << gallery_report_to_json(r).dump(2) << "\n"; +} + +/// TRACES: GR-003 | SR-001 +inline GalleryReport load_gallery_report(const std::string& path) { + std::ifstream in(path); + if (!in.is_open()) + throw std::runtime_error("load_gallery_report: cannot open " + path); + nlohmann::json j; + in >> j; + return gallery_report_from_json(j); +} + +/// TRACES: GR-003 | SR-001 +/// +/// The report's headline, on stderr, at build time. The file is the audit trail; +/// this is what stops a bad gallery from being shipped without anyone noticing. +inline void log_gallery_report(const GalleryReport& r) { + std::cerr << "[gallery-report] " << r.actors_in_gallery << " actors / " + << r.embeddings_total << " embeddings" + << " (mean " << r.mean_embeddings_per_actor << " per actor)\n"; + if (r.actors_zero_usable > 0) { + std::cerr << "[gallery-report] WARNING: " << r.actors_zero_usable + << " actor(s) have NO usable image — they can never be recognised:\n"; + for (const auto& n : r.zero_usable) std::cerr << " - " << n << "\n"; + } + if (r.actors_below_positive_threshold > 0) { + std::cerr << "[gallery-report] " << r.actors_below_positive_threshold + << " actor(s) below " << r.min_embeddings_for_positive_pairs + << " distinct references — they contribute no positive pairs and " + "weaken the calibration\n"; + } + if (r.duplicates_removed_total > 0) + std::cerr << "[gallery-report] " << r.duplicates_removed_total + << " near-duplicate reference(s) removed\n"; + std::cerr << "[gallery-report] calibration valid=" << r.calib_valid + << " a=" << r.calib_a << " b=" << r.calib_b + << " intra/inter overlap=" << r.distributions.overlap + << " (intra mean=" << r.distributions.intra_mean + << ", inter mean=" << r.distributions.inter_mean << ")\n"; + std::cerr << "[gallery-report] gallery-derived prior would be " + << r.prior.derived << " (log-odds " << r.prior.derived_log_odds + << "); shipped default " << r.prior.configured_default + << " is in force — reported, not applied\n"; +} diff --git a/tests/test_calibration.cpp b/tests/test_calibration.cpp index 41d4ebe..8728f9e 100644 --- a/tests/test_calibration.cpp +++ b/tests/test_calibration.cpp @@ -168,3 +168,96 @@ TEST_CASE("calibrate_gallery_cached treats hash=0 as always-recompute", "[calibr CHECK(recomputed); CHECK(cal.valid); } + +// ── GR-003 — the build report ──────────────────────────────────────────────── +#include "gallery/gallery_report.hpp" + +namespace { +// A unit vector on one axis. Distinct axes are orthogonal, which is unrealistic +// as a same-actor cluster but irrelevant here: these tests count actors, they do +// not assess fit quality. +Embedding unit_axis(int slot) { + Embedding e{}; + e[slot % 512] = 1.0f; + return e; +} +} // namespace + +TEST_CASE("report surfaces actors that can never be recognised", "[report][GR-003]") { + // An actor with no usable image is a silent recall ceiling: the pipeline + // will never name them, and nothing in the gallery says why. This is the + // single most useful number in the report. + ActorGallery g; + for (int a = 0; a < 3; ++a) { + ActorGallery::Actor act; + act.name = "actor" + std::to_string(a); + if (a != 1) // actor1 gets nothing + for (int i = 0; i < 6; ++i) act.embeddings.push_back(unit_axis(a * 10 + i)); + g.actors.push_back(std::move(act)); + } + + std::vector flat; + std::vector flat_actor; + for (int a = 0; a < static_cast(g.actors.size()); ++a) + for (const auto& e : g.actors[a].embeddings) { flat.push_back(e); flat_actor.push_back(a); } + + GalleryCalibrationStats stats; + GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats); + GalleryReport r = build_gallery_report(g, cal, stats); + + // An actor present in the gallery with no embeddings is counted as + // in-gallery but contributes nothing; the zero-usable list is populated + // from the build audit, which a stored gallery cannot supply. + CHECK(r.actors_in_gallery == 3); + CHECK(r.actors[1].references == 0); +} + +TEST_CASE("report surfaces actors too thin to calibrate on", "[report][GR-003]") { + // Below the positive-pair threshold an actor contributes nothing to the + // intra-class side of the fit. They are not broken, so nothing complains — + // they just quietly weaken every threshold downstream. + ActorGallery g; + for (int a = 0; a < 2; ++a) { + ActorGallery::Actor act; + act.name = "actor" + std::to_string(a); + const int n = (a == 0) ? 6 : 2; // actor1 is under-referenced + for (int i = 0; i < n; ++i) act.embeddings.push_back(unit_axis(a * 10 + i)); + g.actors.push_back(std::move(act)); + } + + std::vector flat; + std::vector flat_actor; + for (int a = 0; a < static_cast(g.actors.size()); ++a) + for (const auto& e : g.actors[a].embeddings) { flat.push_back(e); flat_actor.push_back(a); } + + GalleryCalibrationStats stats; + GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats); + GalleryReport r = build_gallery_report(g, cal, stats); + + CHECK(r.actors_below_positive_threshold >= 1); +} + +TEST_CASE("report round-trips", "[report][GR-003]") { + ActorGallery g; + ActorGallery::Actor act; + act.name = "solo"; + for (int i = 0; i < 6; ++i) act.embeddings.push_back(unit_axis(i)); + g.actors.push_back(std::move(act)); + + std::vector flat; + std::vector flat_actor; + for (const auto& e : g.actors[0].embeddings) { flat.push_back(e); flat_actor.push_back(0); } + + GalleryCalibrationStats stats; + GalleryCalibration cal = calibrate_gallery(flat, flat_actor, &stats); + GalleryReport r = build_gallery_report(g, cal, stats); + + const std::string path = "/tmp/gr003_roundtrip.report.json"; + save_gallery_report(path, r); + GalleryReport back = load_gallery_report(path); + + CHECK(back.actors_in_gallery == r.actors_in_gallery); + CHECK(back.actors_below_positive_threshold == r.actors_below_positive_threshold); + CHECK(back.calib_a == r.calib_a); + std::remove(path.c_str()); +}