Python reference¶
The seams worth programming against, not every module. LabeloxAV has roughly 600 Python modules; auto-dumping all of them produces a reference nobody can navigate and that goes stale the moment an internal helper moves. What follows is the surface that is deliberately stable: the domain-pack contract, the engine's bridge to it, and the measurement primitives.
The domain pack contract¶
A pack is how one engine serves two domains. packs/av is autonomous driving; packs/sec is physical
security. The engine never imports either: .importlinter forbids core, services and db from
importing packs.av or packs.sec, and everything goes through the registry.
packs.base.DomainPack ¶
Bases: Protocol
The contract a concrete pack satisfies. Attribute-only Protocol: any object exposing these fields is a DomainPack (runtime_checkable checks presence). scene_model / ingestion_adapters are Optional / empty until SEC-M2 wires the scene-model fork; every other surface is real in SEC-M1.
Source code in packs/base.py
packs.base.OntologySpec
dataclass
¶
The taxonomy plus the ontology facts the audit found in code rather than in the governed YAML.
yaml_path is the governed, versioned artifact (unchanged for AV). stuff_names / stuff_l0 externalise the panoptic split that was a frozenset in services/autolabel/ontology.py:31. supported_core externalises core/config.py:396. superclass_map / size_priors are consumed when the quality reviewer moves behind the pack in SEC-M5; they are None until then (honest incremental fill, not a stub).
Source code in packs/base.py
packs.base.CliqueSpec
dataclass
¶
The confusion cliques of a domain, and the cost of confusing classes across them.
Held by the pack because which classes a model confuses is a fact about the world it works in: a scooter and a motorcycle are one decision on an Indian road and two unrelated objects in a warehouse.
Source code in packs/base.py
pair_cost ¶
Cost of confusing a for b. Zero for a class with itself, the clique cost within one, else cross.
Source code in packs/base.py
packs.base.RelationSpec
dataclass
¶
The relationship vocabulary, and which ordered class pairs can stand in which relation.
Two disjoint vocabularies are live in the engine today and neither knows about the other:
services/api/routers/objects.py validates {rider_of, towed_by, part_of, member_of, occludes} on the
editor path, while services/intelligence/scene_graph.py inserts {occluded_by, following,
crossing_in_front_of, parked_near} directly and never passes that validation. occludes and
occluded_by are the same fact in opposite directions and both are stored, so a query for one silently
misses half the corpus.
kinds is the union any writer may use. overlap_pairs maps an ordered (subject l1, object l1) pair
to the relation an overlapping pair of those superclasses probably stands in, which is what lets
relationship-aware NMS keep a rider and their motorcycle apart and say why. inverse records the pairs
that are one fact in two directions, so a reader can normalise rather than guess.
Source code in packs/base.py
canonical ¶
The direction this engine stores. An inverse maps to its canonical form; anything else is itself.
packs.base.TrackEventSpec
dataclass
¶
The track-event vocabulary of a domain.
Source code in packs/base.py
packs.base.ContextSpec
dataclass
¶
Frame-level facts a domain wants recorded that are not about any one object.
Kept separate from the object attribute schema because the question is different: an object attribute
says what a thing is, a context attribute says what the whole scene was like, and the same frame carries
one of each per axis. The engine already stores this on Frame.scene, which the ingest classifier fills
with density, weather, road_type and time_of_day; this is the vocabulary a person may add to it.
A domain with nothing to say about the scene leaves it empty and the editor shows no context panel, rather than the engine inventing weather categories for a warehouse camera.
Source code in packs/base.py
validate ¶
Errors, empty when valid. Same shape as the ontology's attribute validation so a caller that already handles one handles the other.
Source code in packs/base.py
packs.base.RegionSpec
dataclass
¶
How a domain turns a recorded place string into a stratum.
A corpus records places the way the capture rig happened to spell them, and the strings are not a
taxonomy: this one says BLR on 372 sessions and Bengaluru on one, which is a single city that looks
like two strata to anything counting coverage.
resolve maps a lowercased, punctuation-free string to (city, state, urban_class), or None when the
string names nowhere this domain models. classes is the urban_class vocabulary, ordered from largest,
so a report can render the strata in a meaningful order rather than alphabetically.
Source code in packs/base.py
The engine's bridge¶
Every per-domain question the engine asks goes through here, so a second domain behaves differently at runtime rather than only existing in the registry.
services.domain ¶
Engine-facing access to the active domain pack.
The single seam the engine uses to consult the pack it belongs to, without statically importing any concrete
pack (it goes through packs.registry, the sanctioned bridge). Governance, curation, and eval call these
instead of the {"vru","animal"} literals and hardcoded strata the audit found copy-pasted across the tree.
Every helper takes an optional pack_id and defaults to the configured active pack ('av'), so the legacy paths
resolve to the AV pack and stay byte-identical. Per-session routing is pack_for_session: a session records
the pack it was captured under (Session.pack_id), and passing that id here is what makes the second domain
behave differently at runtime rather than only existing in the registry.
active_pack ¶
pack_for_session
async
¶
The DomainPack a session belongs to. The routing seam every per-session capability check goes through.
safety_l1 ¶
The l1 superclasses that define a safety-critical class for the active pack (AV: {'vru','animal'}).
critical_class_names ¶
critical_class_ids ¶
The active pack's safety-critical classes resolved to ids against onto. Replaces the hardcoded
(and 0-based-buggy) verdyx CRITICAL_CLASSES with names resolved through the ontology.
Source code in services/domain.py
context_spec ¶
The active pack's frame-level context vocabulary, or None when the domain has nothing to say about the scene as a whole. A static-camera pack counting entries is complete without one, and the editor offers no context panel rather than the engine inventing weather categories for a warehouse.
Source code in services/domain.py
validate_context ¶
Errors, empty when valid. A pack with no context spec rejects everything: writing a frame-level fact into a domain that declares none is a caller bug, not an empty vocabulary to be filled in silently.
Source code in services/domain.py
track_event_spec ¶
The pack's track-event vocabulary, or None when the domain has no behaviour worth spanning.
validate_track_event_type ¶
Empty when this pack admits this event type on a track of this superclass, else the reasons.
Applicability is checked here and not only in the picker, because the picker is not the only writer: the
proposers and any importer come through the same door, and a lane_splitting event on a pedestrian
track is a wrong statement whichever of them made it.
Source code in services/domain.py
class_aliases ¶
Every word that names this class, the display name first. Never empty.
Declared in the pack's own ontology YAML, which OntologySpec.yaml_path is the pack's statement of
where its class tree lives, and read back through the loader that already parses it. Not a second
mapping on the Pack dataclass: two lists of synonyms for one class tree is how the synonyms drift.
Source code in services/domain.py
Ontology¶
services.autolabel.ontology.Ontology
dataclass
¶
Source code in services/autolabel/ontology.py
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | |
by_id ¶
by_name ¶
has_name ¶
attrs_for_class ¶
Attribute names applicable to a class. None means all attributes apply.
The l1 scope, plus any per-class extras. Union rather than override: a city_bus is still a heavy vehicle and still wants the heavy attributes, it just also wants two of its own.
Source code in services/autolabel/ontology.py
aliases_for ¶
What else this class is called, the display name first. Never empty.
validate_attrs ¶
Return a list of validation errors; empty means valid. When class_id is given and its subclass declares an attribute scope, an attribute not in that scope is an error (not applicable to class).
Source code in services/autolabel/ontology.py
derive_attrs ¶
Return attrs with every derived attribute recomputed from its source.
Called after the merge on every write path, so the derived value is a fact about the stored attrs
rather than a second thing to keep in step. A derived key whose source is absent or out of scope is
removed: leaving a stale triple_riding: true behind after somebody corrected the occupant count to
one is worse than not having the attribute.
Source code in services/autolabel/ontology.py
The confidence gate¶
Where humans enter. Read the module docstring before quoting any threshold: a threshold is a precision floor only for a class with a fitted operating point, and the configured constants are not that.
services.autolabel.gate ¶
The confidence gate: where humans enter (Principle 04). Calibrated confidence routes each object to auto_accept, review, or annotate.
What a threshold here means, stated once so this file cannot argue with itself. A threshold is a precision
floor ONLY for a class with a fitted, measured operating point (ThresholdFit). For every other class the
gate falls back to the configured constants - auto_accept: 0.45, safety_auto_accept: 0.47 on a
calibrated scale whose ceiling sits near 0.48 - and those are unmeasured constants, not floors.
_threshold_for logs exactly that on first use per class. An earlier version of this docstring quoted
0.99/0.95 as the operating point; those numbers never described the running config, and the realized
precision of the auto-accepted subset has not been measured against human verdicts. (A VLM judge puts the
pooled auto_accept subset at 0.932 strict on 44 decided crops - evidence, not a measurement against
humans.)
M-Q.4 hardening: - Per-class calibrated thresholds replace the global constant where a fit exists; safety-critical classes (VRU, animal) use the higher safety constant where it does not. - A rare/fallback class earns auto-accept only with cross-path agreement AND VLM confirmation, never on one model's output. This kills confident-but-wrong rare detections. - The quality reviewer's verdict (geometric/contextual nonsense) demotes an object before it can auto-accept, regardless of score.
gate_object ¶
gate_object(
obj,
onto,
cfg,
auto_accept_enabled=True,
quality_ok=True,
fitted=None,
joint=None,
tube=None,
)
Route one object to auto_accept, review or annotate.
joint is a fitted services/oraclyx/joint_calibration.py surface and tube this object's temporal
coherence. When both are present the threshold is applied to P(correct | conf, tube) rather than to
the raw confidence, which is the point of fitting the surface: a 0.55 detection that is the twentieth
frame of a stable track and a 0.55 detection that appears once are not equally likely to be right, and
a threshold on confidence alone cannot separate them.
Absent either, the score stays the confidence. Substituting the surface's no-tube fallback silently would change what every existing caller gates on, so the joint path is opt-in at the call site.
Source code in services/autolabel/gate.py
needs_vlm ¶
Path C (VLM) duty-cycle predicate. True only for the uncertain subset: paths disagree, confidence in the (per-class) review band, a rare/fallback class, a mask conflict, or a quality-flagged object that a second look should confirm or kill. Never the full stream.
Source code in services/autolabel/gate.py
vlm_confirmed ¶
The VLM saw this object and confirmed (did not overrule) its class.
Measurement¶
The judge, its calibration, and the correction that makes a machine-derived precision quotable.
services.labelops.vlm_review ¶
A VLM judging existing labels in bulk, and an honest account of how much that judgement is worth.
The corpus has 253 human verdicts across 570,379 objects. Precision is unmeasurable, the gate cannot be tuned against anything, and the error detector holds 298,529 candidates with one confirmed verdict. Human review does not reach that scale and will not; a machine judge does.
The temptation is to run the judge and report its agreement rate as precision. That is wrong in a way that is hard to see later, because the number looks like a measurement. A judge has its own error rate, so its agreement rate is a blend of how good the labels are and how good the judge is, and nothing downstream can separate them again.
So this module does three things, and the third is the one that matters:
prereview_batchjudges every object in a batch and records what it said, inmachine_verdict, which is deliberately not the humanreviewtable.judge_agreementcompares the judge against humans wherever both have ruled on the same object, which gives the judge's sensitivity and specificity.judged_precisionreports the raw agreement rate AND the rate corrected for the judge's measured error, and refuses to correct when nobody has adjudicated enough to measure the judge.
The asking is deliberately not the autolabel prompt. Path C asks "what is this?", offering a shortlist and
taking the answer as a proposal. A judge is asked "the label says X, is that right?", which is a different
question with a different failure mode: a model asked to name something will always name something, while a
model asked to confirm can decline. unsure is kept as a first-class answer for exactly that reason, and is
never folded into either side, because a judge that abstains on the hard crops and is scored only on the
easy ones reports a precision that flatters itself.
build_judge_prompt ¶
Ask whether the existing label is right, rather than asking what the object is.
The difference is not cosmetic. A model asked to name an object always names one, so its answer carries no signal about whether it was sure. Asked to confirm a specific claim it can decline, and the declines are exactly the crops worth a person's time.
The alternatives are offered so that "incorrect" can come with a proposal, which turns a rejected label into a correction instead of just a deletion.
Source code in services/labelops/vlm_review.py
parse_judge_reply ¶
Normalise a judge reply, refusing anything that is not one of the three verdicts.
A reply that does not parse becomes unsure rather than being dropped. Dropping it would silently
shrink the denominator, which biases the rate upward: the crops a judge garbles are not a random subset.
A rejection that names the asked class as its own correction is not a rejection. It happens most on
object_fallback, where "is this the right label?" is a confusing question about a class that means
none of the above, and the model answers incorrect while its stated reason confirms the label
("the object is a fire hydrant, which is not in the provided class list"). Counting that as an error
would report the fallback class as broken on exactly the crops it is working. unsure is the designed
answer for a reply carrying no usable opinion, and this is one.
Source code in services/labelops/vlm_review.py
judge_objects
async
¶
Judge a supplied list of objects and record what the judge said.
The loop, separated from how the objects were chosen. prereview_batch picks a flywheel batch;
services/labelops/class_precision.py picks a random sample of one class. Both want the same judging,
the same idempotent upsert and the same chunked commits, and a second copy of this loop is a second
place for the prompt, the abstention handling and the uniqueness key to drift.
Source code in services/labelops/vlm_review.py
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 | |
prereview_batch
async
¶
Judge every object in a flywheel batch and record what the judge said.
Idempotent by construction: the verdict table is unique on (object, judge, model_version, batch_id), so
re-running the same judge updates in place rather than double-counting, a different model version writes
its own row so two judges stay comparable on the same crops, and an object appearing in two batches
keeps a verdict in each. skip_judged additionally avoids paying for calls that would only overwrite an
identical verdict, which matters when the batch is 300 crops and the judge is a metered API.
The judging itself is judge_objects; this function only decides which objects to judge.
Source code in services/labelops/vlm_review.py
judge_agreement
async
¶
Measure the judge against humans on the objects where both have ruled.
This is the step that makes a machine-derived precision worth quoting. Without it the judge's agreement rate is just a number, because there is no way to tell a corpus with 15% bad labels judged perfectly from a corpus with 5% bad labels judged badly.
Human ground truth is read from the object state a reviewer moved it to: accepted or submitted means the person agreed the label was right, rejected means they did not. Objects a human only reclassified count as incorrect, since reclassifying is disagreeing with the original label.
Source code in services/labelops/vlm_review.py
judged_precision
async
¶
Precision from machine verdicts, with the judge's own error corrected for where it can be measured.
Returns both numbers on purpose. raw is what the judge said, which is what a naive implementation
would have reported as precision; corrected is that rate inverted through the judge's measured
sensitivity and specificity. When nobody has adjudicated enough for those to exist, corrected is null
and caveat says why, rather than quietly falling back to the raw number.
The judge is measured on this same batch by default, and that default is a statistical claim rather
than a convenience. A judge's sensitivity and specificity are properties of the population it is
judging, not constants: one that separates pedestrians from poles cleanly may be much weaker on
autorickshaw against e_auto. Measuring it on everything it has ever judged and applying that to one
batch silently assumes the batches are alike. Pass agreement_batch_id to measure on a different
population when that assumption is the one you actually want, for instance a dedicated adjudication set
covering a wider slice than the batch under test.
Source code in services/labelops/vlm_review.py
450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 | |
services.labelops.class_precision ¶
Per-class label precision, measured rather than inferred from confidence.
Confidence is the obvious proxy for "which classes are wrong" and it is a poor one. It is the detector's
opinion of its own output, so a class the detector is confidently wrong about looks healthy and a class it
is diffidently right about looks broken. object_fallback sits at mean confidence 0.806 while meaning
"I do not know what this is", and traffic_signal sits at 0.271 across 64,741 objects. Neither number tells
you whether the label is right.
So this asks a judge, per class, and reports the answer with its uncertainty. Everything about the judging
is services/labelops/vlm_review.py unchanged: the same "the label says X, is that right?" prompt, the same
first-class unsure, the same MachineVerdict plane that is deliberately not the human review table, and
the same Rogan-Gladen correction through the judge's measured sensitivity and specificity. The only thing
that is new here is which crops get judged.
The sample is random within the class, never the highest-scoring. A class's most confident detections are
its best case, and measuring those reports how good the detector is when it is surest, which is not what a
remediation decision needs to know. This mirrors services/errordetect/judge_detectors.py::sample_candidates.
Human-reviewed objects are excluded. An object a person already ruled on is not evidence about the machine's precision, and including them inflates the rate by exactly the amount of review that has happened.
This is a GPU job and it behaves like one. It takes the core/gpu_slot.py advisory lock for the
duration, so it cannot run beside a training run, a corpus relabel or an autolabel pass - any two of those
on one card is an out-of-memory part way through a batch, which the caller counts as a failed unit rather
than as contention. It yields to a live training job rather than competing with one. And it checks free VRAM
between batches and waits rather than pushing the card into swap, because a judge sweep is worth nothing
compared to the training run it would take down.
sample_class
async
¶
A pseudo-random sample of one class's machine-labelled objects, stable across runs.
Ordered by a hash of the object id and the seed, not by random(). setseed plus ORDER BY random()
looks reproducible and is not: random() is evaluated per row in scan order, so the draw survives a
repeat only while the query plan and the table are unchanged. Both change here. A remediation sweep
rewrites class_id on exactly the rows this selects, so the follow-up measurement would draw a
different sample and the difference between the two numbers would be partly the fix and partly the
draw - which is the one thing the before/after comparison exists to avoid. Measured: a re-run with the
identical seed took sedan from 80 stored verdicts to 128, meaning 48 fresh crops.
A hash of the id is stable against both. An object keeps its position whatever else happens to the table, so the second sample is the first one plus or minus only the rows that genuinely left the class.
min_side_px drops crops too small to judge. A 6-pixel box is not a label the judge can rule on, and an
unsure from an unjudgeable crop tells you nothing about the class while still costing a call.
Source code in services/labelops/class_precision.py
judge_class
async
¶
judge_class(
db,
class_name,
*,
n=120,
seed=0.42,
min_side_px=12.0,
client=None,
model_version=None,
batch=BATCH,
take_slot=True,
)
Judge a random sample of one class and record the verdicts. Returns the judging summary.
Runs in batches of batch crops, re-checking the card between them, so a training job that starts
mid-class waits seconds rather than the length of the class. Verdicts upsert, so a sweep that stops
early has still banked everything it judged and a re-run resumes rather than restarting.
Reporting is deliberately not done here: services/labelops/vlm_review.py::judged_precision already
turns verdicts into a raw Wilson interval and a corrected one, and already refuses to correct when the
judge is unmeasured. A second precision calculation here would be a second thing to keep honest.
Source code in services/labelops/class_precision.py
class_targets
async
¶
The classes worth measuring: the biggest ones, because that is where being wrong costs most.
Volume rather than suspicion, on purpose. Picking the classes that already look bad measures a hypothesis instead of testing it, and a class that is large and quietly wrong is the expensive case.
Source code in services/labelops/class_precision.py
services.labelops.sampling ¶
How many to check, and what the answer is worth once you have.
Every quality number this system reports is a bare point estimate. measured_precision returns a fraction,
honeypot_accuracy returns a ratio, the overnight auditor samples a hardcoded 200. None of them says how
sure it is, so "precision is 0.87" reads identically whether it came from 12 objects or 12,000, and a
customer buying a quality claim is buying the interval as much as the number.
Two things live here.
A Wilson interval, not the textbook normal approximation. At the rates that matter here, a defect rate near 0 or near 1 on a few hundred samples, the normal interval is wrong in the direction that flatters: it produces bounds below zero for a clean batch and is far too narrow when p is extreme. Wilson stays inside [0, 1] and holds its coverage at small n, which is the whole regime this corpus is in.
And a sample size, so "check some" becomes a number somebody can plan around.
Sampling for precision is not sampling for improvement, and the difference matters. The active-learning queue deliberately surfaces the hardest, most uncertain objects, which is right for teaching the model and ruinous for measuring it: judging that batch tells you the accuracy of the worst objects in the corpus, not of the corpus. A precision estimate needs a sample that is random with respect to correctness.
wilson_interval ¶
A proportion with its uncertainty. Returns {p, lo, hi, n, half_width}.
n = 0 gives the whole interval rather than an error, because "we have not checked any" is a real state and reporting it as 0.0 precision would be a lie in the confident direction.
Source code in services/labelops/sampling.py
rogan_gladen ¶
Correct a rate measured by an imperfect judge for that judge's own error.
The reason this is needed rather than optional. A VLM can judge 570,379 labels; a person cannot. But the judge is wrong sometimes, and quoting its raw agreement rate as precision embeds its error in every number downstream, in an unknown direction. If the judge is 90% sensitive and calls 85% of labels correct, the true rate is not 85%.
Measure the judge against a human-adjudicated subsample to get its sensitivity (it says correct when the label is correct) and specificity (it says incorrect when the label is wrong), then invert:
p_true = (p_observed + specificity - 1) / (sensitivity + specificity - 1)
the standard Rogan-Gladen prevalence estimator. Returns None when sensitivity + specificity <= 1, which means the judge carries no information (at exactly 1 it is a coin, below it is anti-correlated) and no correction can recover a rate from it. That is a real state and worth refusing to answer for, since the formula happily returns a confident-looking number either side of the singularity.
Clamped to [0, 1]: sampling noise in a small subsample can push the estimate outside the range it is estimating, and a precision of 1.04 is less useful than a precision of 1.0 with a wide interval.
Source code in services/labelops/sampling.py
rogan_gladen_interval ¶
Correct a rate for an imperfect judge, carrying the judge's own uncertainty through.
The point version collapses three uncertain quantities into one confident-looking number, and on real data that is worse than useless. Measured on this corpus the judge came out at sensitivity 0.76 (0.65 to 0.84) and specificity 0.80 (0.65 to 0.90), which puts the estimator's denominator anywhere between 0.30 and 0.74. A denominator uncertain by a factor of two makes the corrected rate uncertain by a factor of two, and quoting its midpoint would hide exactly that.
So the correction is evaluated at both ends of the judge's intervals. Note this is the range implied by
the judge's uncertainty alone; the sampling error in observed_p is reported separately by the caller
and the two are not combined, because combining them would imply a joint interval nobody computed.
clamped is the important flag. Rogan-Gladen is unbounded, so a judge whose measured error cannot
explain the observed rate produces an estimate above 1.0 or below 0.0, which then gets clipped into
range and reads as a confident 1.0. That is a signal that the model does not fit, not an answer, and it
has to be visible.
Source code in services/labelops/sampling.py
Temporal¶
Filling frames between detections, and refusing to when the anchors are not one object.
services.temporal.gap_gate ¶
Whether two detections on a track are plausibly the same object, and therefore whether the hole between them may be filled.
This is the gate that was missing, and its absence is the whole defect. Interpolation arithmetic is not what went wrong in the 137,913-object gap fill: printing a track's boxes in time order shows the fills bridging two real detections smoothly and landing where they should. What went wrong is that most of those pairs were not the same object. 45.9% of track steps have zero box overlap and 59.2% of tracks contain a centre jump of more than a quarter of the frame width, because the tracker's feasibility test admits a match on appearance alone at zero overlap. Interpolating across such a pair draws a smooth path between two unrelated things and puts every box on empty road.
The gate is worth its own module because the same predicate answers two questions: may this hole be filled, and should this track be split here. One definition, so the splitter and the filler cannot disagree about what a discontinuity is.
Measured against the gaps that were actually filled: 64.6% of endpoint pairs stayed within the displacement bound, 70.3% within the scale bound, 34.4% agreed on class, and 20.9% passed all three. Judged precision of the objects produced was 0.209. The correspondence is the evidence that this is the right cut.
GateResult
dataclass
¶
Why a hole was or was not filled. reason is None when it passed.
Source code in services/temporal/gap_gate.py
same_object ¶
same_object(
box_a,
box_b,
class_a,
class_b,
*,
frame_width,
gap_frames,
cliques=None,
max_travel_frac=MAX_CENTRE_TRAVEL_FRAC,
max_area_ratio=MAX_AREA_RATIO,
max_gap_frames=MAX_GAP_FRAMES,
)
Could these two detections be the same object, such that the frames between them may be filled?
Deliberately three cheap geometric and semantic tests rather than an appearance model. Appearance is what the tracker already over-trusted: its gate admits a match when the DINOv3 cosine clears 0.55, which two arbitrary same-class vehicles routinely do. Geometry is the signal appearance was allowed to override.
cliques is the pack's CliqueSpec. Class equality is deliberately not required: the detector renames
one object between consecutive frames - a single receding vehicle in this corpus is labelled truck,
rider, autorickshaw, suv and motorcycle on five consecutive frames - so exact matching would reject
65.6% of holes, most of them genuine. A shared confusion clique tolerates that instability without
letting a pedestrian bridge to a bus.
Source code in services/temporal/gap_gate.py
is_discontinuity ¶
True when a track step cannot be one object continuing, and the track should be cut here.
The geometric half of same_object, without the class test: a track that changes class is the
detector being unstable, while a track that teleports is two objects wearing one id. Splitting on class
alone would shred correct tracks.
Source code in services/temporal/gap_gate.py
services.temporal.interpolate ¶
Filling the frames between anchors on a track, and refusing to when the anchors are not one object.
Between anchors, boxes are interpolated in centre-and-size space with a shape-preserving spline and marked
source=interpolated with an interp_source, so provenance shows they are machine-filled.
The refusal is the important part. An earlier corpus-wide fill created 137,913 objects that judge at
0.209 precision against 0.603 for real detections. The arithmetic here was never the problem - printing a
track's boxes in time order shows the fills landing exactly where they should. The problem is that most
anchor pairs were not the same object: only 20.9% of the holes that were filled had endpoints within a
plausible displacement, scale and class of each other, and 0.209 of the objects produced were right. So
every hole now passes services/temporal/gap_gate.py::same_object before anything is written, and a run
reports what it declined and why rather than only what it made.
Two anchor policies, because the two callers want different things and conflating them is how the corpus
fill went wrong. keyframe anchors on human-verified or explicitly marked boxes, which is what the editor
means by interpolating between keyframes - but only 179 of 11,406 tracks have two of those, so it is useless
for a backfill. detection anchors on detector output. Neither ever anchors on interpolated or
propagated boxes: the old implementation treated every object as an anchor, so its own output re-anchored
the next run and the errors compounded.
interpolate_track_keyframed
async
¶
interpolate_track_keyframed(
track_id,
method="linear",
lo_ts=None,
hi_ts=None,
*,
anchor_policy="keyframe",
run_id=None,
gate=True,
)
Fill frames between anchors with interpolated boxes, skipping holes whose anchors are not one object.
anchor_policy selects what counts as an anchor - see ANCHOR_SOURCES. run_id makes the fill revertible:
every created object is stamped with it and recorded as {"created": True} on the run, which is the
shape services/agent/runs.py::revert_run already deletes. gate=False is for the editor's explicit
"interpolate between the two boxes I just drew", where a person has already asserted they are one object.
Returns per-hole accounting, including refused keyed by reason. A fill that reports only what it
created cannot be distinguished from one that created the wrong thing, which is how the last one passed.
Source code in services/temporal/interpolate.py
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | |
build_box_interpolator ¶
Return (box_at, src). box_at(ts) gives an [x1,y1,x2,y2] box for any ts inside the keyframe span.
method='cubic' uses a shape-preserving monotone spline (PCHIP) on center and size. Unlike an ordinary cubic it does not overshoot between anchors (no Runge wobble, no box that briefly balloons or inverts), while still curving through the acceleration a straight line would miss. Falls back to linear with <3 keyframes or if SciPy is unavailable.
Source code in services/temporal/interpolate.py
Reversibility¶
Every corpus-wide write in this system is undoable through one of these.
services.review_apply ¶
Applying one review decision to a set of objects, in one place.
services/review_policy.py opens by saying the state rule "lives here, the router calls it, and both the
single and the bulk review path go through it". That was true of two paths and not of the third:
POST /tracks/{id}/relabel wrote o.state = payload.state straight from the request body with a default of
accepted, so an annotator could confirm an entire track and skip the QA step the whole two-stage workflow
is built on. It also never advanced version, never recorded a revertible batch, and never revalidated
attributes, so it was the one bulk write in the repo with no undo and no lock.
Rather than copy four rules into a third router, the per-object loop bulk review had already grown lives
here and every caller gets the same guarantees. Bulk review's observable behaviour is unchanged by the
move, which tests/test_bulk_review_hardening.py is the proof of.
Two behaviours are opt-in because the two callers genuinely differ, not because one of them is lazy:
skip_human - a track relabel must not overwrite a frame somebody else already ruled on, the same rule
services/agent/temporal_repair.py applies and the correction dialog shows as an "already" badge. Bulk
review is an explicit list of ids a person just ticked, so there is nothing to protect them from.
guard_class_move - the ontology guard from services/agent/class_move.py refuses a move that changes what
kind of thing something is. A track relabel is one decision fanned across ninety frames and is worth
guarding. Bulk review deliberately spans classes: the correction dialog exists to gather one systematic
error that is usually spread over several source classes, so guarding it there would break the feature
commit 38b28dd built.
apply_review_batch
async
¶
apply_review_batch(
db,
objects,
*,
action,
onto,
class_id=None,
attrs=None,
requested_state=None,
role=None,
source="human",
reviewer="anon",
uid=None,
expected_versions=None,
time_spent_ms=0,
provenance_extra=None,
skip_human=False,
guard_class_move=False,
revalidate_attrs=False,
)
Apply one decision to every object given, returning what happened to each.
Adds rows to the session and does not commit: the caller lands the edits and the batch stamp in one
transaction, for the reason services/review_batch.py records at length.
Source code in services/review_apply.py
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | |
services.review_batch ¶
Undo for a bulk review, and why the generic revert cannot do it.
Bulk review writes one Review row per object, which is the audit trail and answers "who changed this and
what was it before". What it did not write was anything tying the fifty objects together, so a fifty-object
mistake was fifty manual reversals, each one requiring the operator to remember what the value had been. The
correction dialog inherits this: it exists to apply one decision widely, which is exactly the operation most
worth being able to take back in one move.
AgentRun is already the repo's revertible unit and services/agent/runs.py revert_run already restores
from_class, from_state, from_source and from_attrs. It cannot be reused as is, for a reason its own
comment states: it refuses to touch anything whose source is human, which is right when undoing an
agent's work over a person's, and wrong here because a human review is what set source = "human" in the
first place. Every object in the batch would be skipped.
So this kind gets its own revert, the way ontology_merge and cleanup_sweep already do. The ownership
check that keeps it safe is not the source column but the run id stamped in provenance: an object is
restored only while it still carries THIS run's id, so anything edited afterwards, by a person or by a later
batch, is left alone and reported as skipped rather than silently rolled back.
change_record ¶
What has to be remembered about one object for the batch to be undoable.
Captured before the edit. from_source matters as much as from_class: a machine label promoted to
human by a batch that turns out to be wrong must go back to being a machine label, or the corpus quietly
gains human-authored rows nobody authored.
Source code in services/review_batch.py
record_batch
async
¶
Tie an applied batch together so it can be taken back. Returns the run id, or None for an empty batch.
Stamps each object with the run id, which is what the revert checks ownership against.
commit=False lets the caller land the stamp in the same transaction as the edit it describes. That is
the correct way to use this: the review router used to commit the edits and only then call this, on the
reasoning that a failed edit must not leave a run claiming objects it never changed - true, but it
bought that by opening a window where the process could die between the two and leave the batch
permanently un-revertible, because revert_batch keys ownership on precisely this stamp. One transaction
satisfies both: a failed edit rolls the stamp back with it, and a committed edit is always revertible.
Source code in services/review_batch.py
revert_batch
async
¶
Put a bulk review back, including the objects it made human-sourced.
Ownership is the stamped run id rather than the source column, so a later edit by anybody wins and is
counted as skipped. That is the same protection the generic revert gets from its source == "human"
check, expressed in the one way that still works when the run itself is the reason the row says human.
Source code in services/review_batch.py
services.agent.runs ¶
AgentRun helpers: serialize a run for the API, and revert one exactly. Revert restores each object's prior state/source from the recorded transition, but skips any object a human has touched since the run (source now "human", or the stamped agent_run_id no longer matches) -- the agent never overwrites a person.