The live path abstained too much: a detected product whose best candidate missed the accept line was left blank, so a shelf where 59% of in-catalog facings were shown read as broken. Show the best candidate immediately as `provisional`, keep the track re-classifiable so a later attempt upgrades or corrects it, and never blank a label already on screen. - identity.py / identity_lanes.py: every decision now carries `provisional_sku` - the row the evidence supports well enough to show, separate from `best_sku`, which still means "cleared the gate". The accept line does not move: far-unknown false accepts stay at 0.066%. - live.py: a below-gate candidate labels the track with `identity_level: provisional` and `confirmed = False`, so the planner and the crop lane keep offering it. Accepted labels stay frozen. Inherited provisional labels stay re-classifiable. - Clients render provisional distinctly (amber chip, dashed box) via `LiveScanOverlayPolicy.isProvisional(identity)`, which now reads the wire level instead of only the state. - build_stock_all.py merges stock, stock-lopia, stock-mediya and stock-milk150 into stock-all (111 images, 60 SKUs). The server could only read one folder, so anything outside it came back blank. stock-demo is byte-identical to mediya+milk150 and is not a source. Measured on add/image6.jpg at live resolution: in-catalog facings shown 59.4% -> 86.0%, correct shown 38 -> 55, precision 97.4% -> 87.3%. On the wire at 8fps 0.5x: 65 of 79 tracks labelled, 742 accepted + 309 provisional observations, first label 1.27s. docs/research/2026-09-29-shelf-intelligence/ carries the experiments and the decision: group signals (combined AUC 0.991 sibling / 0.990 unrelated), OCR scheduling (group representatives cut calls 4.4x and wall 3.3x), and the finding that group propagation damages more labels than it rescues on this shelf, so groups schedule compute rather than overwrite labels. 331 tests pass.
327 lines
18 KiB
Python
327 lines
18 KiB
Python
"""I: bounded temporal pair-cache diagnostic over saved replay timelines.
|
|
|
|
Counters only: hits/misses, unique pairs, updates, absence invalidations, and
|
|
how often a named identity changed between consecutive named observations.
|
|
No latency claim, no SKU propagation, no accuracy claim, no production edits.
|
|
|
|
Reuses the frozen geometry conventions: `collect.rows_from_boxes` (bottom-aligned
|
|
shelf rows) and `analyze.geometric_edge` (the gap/height/bottom edge rule).
|
|
"""
|
|
from pathlib import Path
|
|
import sys
|
|
import json
|
|
import hashlib
|
|
import statistics
|
|
from collections import Counter, defaultdict
|
|
|
|
DIR = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(DIR))
|
|
from analyze import geometric_edge
|
|
from collect import rows_from_boxes
|
|
from eval_identity import code_revision
|
|
|
|
# Declared before looking at any hit count: a member box is "unchanged" while it
|
|
# keeps IoU >= 0.9 with the box the edge was cached against, i.e. under ~10% of
|
|
# the smaller box's area moved between the frame the edge was measured on and
|
|
# the frame it is consulted.
|
|
DECLARED_KEEP_IOU = 0.9
|
|
SENSITIVITY_IOU = (0.8, 0.9, 0.95, 1.0)
|
|
PRIMARY = 'replay-image6-8fps-1x-1.json'
|
|
GROUPS = {'arms': 'evidence', 'legacy_label_anchor_arms': 'evidence/initial-replays'}
|
|
|
|
|
|
def iou(a, b):
|
|
inter = max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
|
|
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
|
|
return inter / union if union > 0 else 0.0
|
|
|
|
|
|
def edge_signals(a, b):
|
|
"""collect.pair_signals' geometry half: a is left of b in a rows_from_boxes row."""
|
|
aw, ah = a[2] - a[0], a[3] - a[1]
|
|
bw, bh = b[2] - b[0], b[3] - b[1]
|
|
return {'gap': (b[0] - a[2]) / max(aw, bw), 'height': min(ah, bh) / max(ah, bh), 'bottom': abs(a[3] - b[3]) / max(ah, bh)}
|
|
|
|
|
|
class PairCache:
|
|
"""Edge memo keyed by track identities AND object generations.
|
|
|
|
A cached edge is reused only when the pair is still adjacent, both members
|
|
still carry the generation it was cached under, and both boxes are still
|
|
inside the declared IoU of the boxes it was cached against. Anything else
|
|
is a miss, and a miss rewrites the entry.
|
|
"""
|
|
def __init__(self, keep_iou=DECLARED_KEEP_IOU):
|
|
self.keep_iou = keep_iou
|
|
self.entries = {}
|
|
self.lookups = self.hits = self.misses = self.updates = 0
|
|
self.drift = []
|
|
self.reasons = Counter()
|
|
|
|
@staticmethod
|
|
def pair(ka, kb):
|
|
return (ka[0], kb[0]) if ka[0] <= kb[0] else (kb[0], ka[0])
|
|
|
|
def get(self, ka, kb, ba, bb, adjacent=True):
|
|
self.lookups += 1
|
|
if not adjacent:
|
|
return self._miss('adjacency')
|
|
entry = self.entries.get(self.pair(ka, kb))
|
|
if entry is None:
|
|
return self._miss('new')
|
|
if entry['keys'] != (ka, kb):
|
|
return self._miss('generation')
|
|
if iou(ba, entry['boxes'][ka]) < self.keep_iou or iou(bb, entry['boxes'][kb]) < self.keep_iou:
|
|
return self._miss('geometry')
|
|
self.hits += 1
|
|
self.drift += [iou(ba, entry['boxes'][ka]), iou(bb, entry['boxes'][kb])]
|
|
return entry
|
|
|
|
def put(self, ka, kb, ba, bb, edge, row):
|
|
pair = self.pair(ka, kb)
|
|
if pair in self.entries:
|
|
self.updates += 1
|
|
self.entries[pair] = {'keys': (ka, kb), 'boxes': {ka: ba, kb: bb}, 'edge': edge, 'row': row}
|
|
|
|
def _miss(self, reason):
|
|
self.misses += 1
|
|
self.reasons[reason] += 1
|
|
return None
|
|
|
|
|
|
def self_check():
|
|
"""A cached edge must not survive a generation change or an adjacency change."""
|
|
a, b = [10, 100, 90, 300], [95, 102, 175, 300]
|
|
ka, kb = (7, 0), (9, 0)
|
|
assert geometric_edge({'signals': edge_signals(a, b)}) is True
|
|
c = PairCache()
|
|
assert c.get(ka, kb, a, b) is None and c.reasons['new'] == 1
|
|
c.put(ka, kb, a, b, True, 0)
|
|
assert c.get(ka, kb, a, b)['edge'] is True
|
|
assert c.get((7, 1), kb, a, b) is None and c.reasons['generation'] == 1
|
|
assert c.get((9, 1), ka, a, b) is None and c.reasons['generation'] == 2, 'unordered key must still catch a generation change'
|
|
assert c.get(ka, kb, a, b, adjacent=False) is None and c.reasons['adjacency'] == 1
|
|
assert c.get(ka, kb, [10, 100, 90, 420], b) is None and c.reasons['geometry'] == 1
|
|
assert c.get(ka, kb, a, b)['edge'] is True, 'a declined lookup must not evict a live entry'
|
|
return {'first_lookup': 'miss/new', 'repeat_lookup': 'hit', 'generation_invalidations': 2,
|
|
'adjacency_invalidations': 1, 'geometry_invalidations': 1, 'entry_survived_declined_lookups': True}
|
|
|
|
|
|
def generations(timelines):
|
|
"""Per-result track_id -> generation, plus where the value came from.
|
|
|
|
live.py increments TrackState.object_generation and _restart_count in the
|
|
same two branches -- a recycled id, and an id absent from the previous
|
|
frame -- off the same counters, so restarts == object_generation for every
|
|
track. object_generation is not in _track_payload, so on the wire restarts
|
|
is the only generation evidence. With neither recorded, the generation falls
|
|
back to a gap proxy, and the proxy is always reported next to the wire value
|
|
so the two can be compared instead of assumed equal.
|
|
"""
|
|
per_result, proxy, recorded, previous = [], {}, set(), {}
|
|
for index, frame in enumerate(timelines):
|
|
current = {}
|
|
for track in frame['tracks']:
|
|
if track.get('object_generation') is not None:
|
|
recorded.add('object_generation')
|
|
if track.get('restarts') is not None:
|
|
recorded.add('restarts')
|
|
for track in frame['tracks']:
|
|
tid = track['id']
|
|
if tid in previous and index - previous[tid] > 1:
|
|
proxy[tid] = proxy.get(tid, 0) + 1
|
|
value = track.get('object_generation')
|
|
if value is None:
|
|
value = track.get('restarts')
|
|
current[tid] = int(value) if value is not None else proxy.get(tid, 0)
|
|
per_result.append(current)
|
|
previous = {track['id']: index for track in frame['tracks']}
|
|
source = next((name for name in ('object_generation', 'restarts') if name in recorded), 'absence_proxy')
|
|
return per_result, source, proxy
|
|
|
|
|
|
def run(path, keep_iou):
|
|
data = json.loads(path.read_text())
|
|
timelines = data['timelines']
|
|
per_result, source, proxy = generations(timelines)
|
|
cache = PairCache(keep_iou)
|
|
named = defaultdict(list)
|
|
unique_ids, unique_pairs, previous_pairs = set(), set(), set()
|
|
per_result_pairs, rows_per_result, adjacency_rebuilds = [], [], 0
|
|
named_obs = unnamed_obs = degenerate = edge_yes = 0
|
|
final_generation, generation_history = {}, {}
|
|
for index, frame in enumerate(timelines):
|
|
tracks = []
|
|
for track in frame['tracks']:
|
|
box = track.get('box')
|
|
if not box or box[2] <= box[0] or box[3] <= box[1]:
|
|
degenerate += 1
|
|
continue
|
|
tracks.append({'id': track['id'], 'box': box})
|
|
unique_ids.add(track['id'])
|
|
final_generation[track['id']] = per_result[index].get(track['id'], 0)
|
|
generation_history.setdefault(track['id'], set()).add(final_generation[track['id']])
|
|
if track.get('sku'):
|
|
named[track['id']].append(track['sku'])
|
|
named_obs += 1
|
|
else:
|
|
unnamed_obs += 1
|
|
rows = rows_from_boxes(tracks)
|
|
current = set()
|
|
for row_index, row in enumerate(rows):
|
|
for left, right in zip(row, row[1:]):
|
|
ka = (left['id'], per_result[index].get(left['id'], 0))
|
|
kb = (right['id'], per_result[index].get(right['id'], 0))
|
|
entry = cache.get(ka, kb, left['box'], right['box'])
|
|
if entry is None:
|
|
edge = geometric_edge({'signals': edge_signals(left['box'], right['box'])})
|
|
edge_yes += int(edge)
|
|
cache.put(ka, kb, left['box'], right['box'], edge, row_index)
|
|
else:
|
|
edge_yes += int(entry['edge'])
|
|
current.add(cache.pair(ka, kb))
|
|
rows_per_result.append(len(rows))
|
|
per_result_pairs.append(len(current))
|
|
unique_pairs |= current
|
|
adjacency_rebuilds += len(current - previous_pairs)
|
|
previous_pairs = current
|
|
unchanged = changed = 0
|
|
for sequence in named.values():
|
|
for a, b in zip(sequence, sequence[1:]):
|
|
unchanged += int(a == b)
|
|
changed += int(a != b)
|
|
disagreeing = {}
|
|
for tid, values in generation_history.items():
|
|
wire_value = final_generation.get(tid, 0)
|
|
if len(values) > 1 or wire_value != proxy.get(tid, 0):
|
|
disagreeing[str(tid)] = {'wire': wire_value, 'gap_proxy': proxy.get(tid, 0),
|
|
'changed_during_run': len(values) > 1}
|
|
total = cache.lookups
|
|
return {
|
|
'results': len(timelines), 'schema_version': data.get('schema_version'),
|
|
'coordinate_basis': data.get('coordinate_basis'),
|
|
'source': data['summary']['source'], 'fps': data['summary']['fps'],
|
|
'rate': data['summary']['rate'], 'trial': data['summary']['trial'],
|
|
'frames': {'sent': data['summary']['sent'], 'returned': data['summary']['processed']},
|
|
'declared_keep_iou': keep_iou,
|
|
'rows': {'total': sum(rows_per_result), 'p50_per_result': statistics.median(rows_per_result),
|
|
'max_per_result': max(rows_per_result, default=0)},
|
|
'adjacent_pairs': {'per_result_recompute_workload': sum(per_result_pairs),
|
|
'p50_per_result': statistics.median(per_result_pairs),
|
|
'unique_pairs': len(unique_pairs),
|
|
'adjacency_rebuilds': adjacency_rebuilds},
|
|
'tracks': {'unique_ids': len(unique_ids), 'observations': named_obs + unnamed_obs,
|
|
'named_observations': named_obs, 'unnamed_observations': unnamed_obs,
|
|
'degenerate_boxes_skipped': degenerate,
|
|
'named_tracks': len(named),
|
|
'distinct_skus': len({sku for sequence in named.values() for sku in sequence})},
|
|
'cache': {'lookups': total, 'hits': cache.hits, 'misses': cache.misses,
|
|
'hit_rate': cache.hits / total if total else None,
|
|
'pair_computations_with_cache': cache.misses,
|
|
'pair_computations_avoided': cache.hits,
|
|
'miss_reasons': dict(cache.reasons), 'updates': cache.updates,
|
|
'entries': len(cache.entries), 'edge_yes_computed_or_reused': edge_yes,
|
|
'hit_drift_iou_vs_cached_box': {'min': min(cache.drift, default=None),
|
|
'p50': statistics.median(cache.drift) if cache.drift else None,
|
|
'max': max(cache.drift, default=None),
|
|
'samples': len(cache.drift)}},
|
|
'generations': {
|
|
'source': source,
|
|
'wire_generation_events': sum(value for value in final_generation.values()),
|
|
'tracks_with_generation_change': sum(1 for values in generation_history.values() if len(values) > 1),
|
|
'gap_proxy_events': sum(proxy.values()),
|
|
'gap_proxy_disagreements': len(disagreeing),
|
|
'gap_proxy_disagreeing_tracks': disagreeing,
|
|
'generation_invalidations': cache.reasons['generation'],
|
|
},
|
|
'identity': {'named_transitions': unchanged + changed, 'unchanged': unchanged, 'changed': changed,
|
|
'note': 'consecutive named observations of one track id; a change means the label flipped, '
|
|
'repeated observations of an unchanged label count neither way'},
|
|
}
|
|
|
|
|
|
def main():
|
|
report = {
|
|
'purpose': 'bounded diagnostic: how much of the geometry-only adjacent-pair work on a replay timeline '
|
|
'is redundant between consecutive results',
|
|
'declared_criterion': {
|
|
'cache_key': '(track_id, generation) for both members, unordered',
|
|
'keep_iou': DECLARED_KEEP_IOU,
|
|
'reuse_requires': ['pair still adjacent in this result row ordering',
|
|
'both member generations unchanged since the entry was written',
|
|
f'both member boxes IoU >= {DECLARED_KEEP_IOU} with the boxes the entry was written against'],
|
|
'cached_value': 'the geometry-only edge verdict (analyze.geometric_edge over collect pair_signals geometry)',
|
|
},
|
|
'self_check': self_check(),
|
|
'provenance': {'code': code_revision()},
|
|
'arms': {},
|
|
'legacy_label_anchor_arms': {},
|
|
'sensitivity_hit_rate': {},
|
|
'limitations': [
|
|
'appearance: only the geometry-only adjacent edge is cacheable from a replay timeline. The appearance '
|
|
'half of analyze.pair_value (hue, pixels, logo_pixels, inliers, ocr_jaccard) needs per-track appearance '
|
|
'features a timeline does not carry, so this is the geometry/adjacency reuse ceiling, not a full '
|
|
'pair-score cache. The counters bound a cache of one boolean, not of descriptor cost.',
|
|
'object_generation: replay.py records it, but live.py _track_payload never populates it, so it is null on '
|
|
'every track in every arm. live.py increments object_generation and restarts in the same two branches '
|
|
'off the same counters, so restarts carries the generation and is what these arms key on. A cache key '
|
|
'naming object_generation specifically is therefore not yet constructible from the wire.',
|
|
'session generation: a method change bumps LiveSession._generation and resets every track in place, '
|
|
'leaving per-track object_generation untouched. A per-track generation key does not cover that; a real '
|
|
'cache needs the session generation in the key too, and no arm here exercises a method change.',
|
|
'absence: a gap between consecutive recorded results is not server absence. Arms return 9 of 40 sent '
|
|
'frames, so a track can be missing from a recorded result while the server never lost it. gap_proxy_events '
|
|
'is reported next to the wire generation and disagrees with it in exactly the arms listed under '
|
|
'gap_proxy_disagreeing_tracks; the wire value is the one used.',
|
|
'box basis differs between the two groups. `arms` is schema 2, coordinate_basis "displayed box mapped '
|
|
'with result frame origin". `legacy_label_anchor_arms` is the archived schema-1 run, where replay.py '
|
|
'took label_box (always non-empty, so raw_box/box never applied) and mapped it with the label frame '
|
|
'pan offset; an unlabelled track has label_frame_id == 0, so its box was mapped with frame 0 origin, '
|
|
'a pure x error of up to ~197 source px on image6 against a median box width of 89 px. The two groups '
|
|
'are not comparable to each other and are reported separately.',
|
|
'scope: arms differ only in pacing and source, and the still-image arms replay one development photo '
|
|
'(add/image6.jpg, 111 labelled facings). No independent generalization, and no native UI or device path '
|
|
'was exercised. Arms are also truncated under server load, so the named primary arm is the smallest of '
|
|
'the set and its counters are the weakest.',
|
|
'counters only: no latency saving is claimed, because the timeline carries no cost for the work a hit '
|
|
'skips, and the declared IoU is a geometric restatement of "the box did not move", not a measurement.',
|
|
'chained drift: the criterion compares each member against the box the entry was last written '
|
|
'against, not against the previous result, so a box that creeps a little every frame can stay under '
|
|
'the IoU forever while ending up far from where the verdict was computed. hit_drift_iou_vs_cached_box '
|
|
'is the observed bound on that; a production cache that needs a hard staleness bound should compare '
|
|
'against the previous result and cap the age of an entry.',
|
|
],
|
|
}
|
|
for group, folder in GROUPS.items():
|
|
for path in sorted((DIR / folder).glob('replay-*.json')):
|
|
if 'timelines' not in json.loads(path.read_text()):
|
|
continue
|
|
arm = run(path, DECLARED_KEEP_IOU)
|
|
report[group][path.name] = arm
|
|
report['provenance'].setdefault('sources', []).append(
|
|
{'group': group, 'file': path.name, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
'primary': path.name == PRIMARY and group == 'arms'})
|
|
report['sensitivity_hit_rate'].setdefault(group, {})[path.name] = {
|
|
str(value): run(path, value)['cache']['hit_rate'] for value in SENSITIVITY_IOU}
|
|
out = DIR / 'evidence/temporal-cache.json'
|
|
out.write_text(json.dumps(report, ensure_ascii=False, indent=2))
|
|
print('self_check', report['self_check'])
|
|
for group in GROUPS:
|
|
for name, arm in report[group].items():
|
|
print(group, name, 'results', arm['results'], 'sent/returned', arm['frames'],
|
|
'unique_pairs', arm['adjacent_pairs']['unique_pairs'],
|
|
'lookups', arm['cache']['lookups'], 'hits', arm['cache']['hits'],
|
|
'misses', arm['cache']['misses'], 'hit_rate', arm['cache']['hit_rate'],
|
|
'reasons', arm['cache']['miss_reasons'],
|
|
'gen_source', arm['generations']['source'],
|
|
'wire_gen_events', arm['generations']['wire_generation_events'],
|
|
'proxy_disagree', arm['generations']['gap_proxy_disagreements'],
|
|
'identity_unchanged', arm['identity']['unchanged'],
|
|
'identity_changed', arm['identity']['changed'])
|
|
print('wrote', out)
|
|
if PRIMARY in report['arms']:
|
|
print('primary', PRIMARY, json.dumps(report['arms'][PRIMARY], indent=1)[:1400])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|