product-recognition-demo/recognization/docs/research/2026-09-29-shelf-intelligence/temporal_cache.py
tngo 0cf64a18bf Coverage-first live labelling, merged catalog, shelf-intelligence decision
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.
2026-09-29 13:45:14 +09:00

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()