diff --git a/progress.md b/progress.md index 8a2620f..9608c23 100644 --- a/progress.md +++ b/progress.md @@ -193,13 +193,47 @@ | 2026-05-29 | Setup | Main Agent | Created Goal for `v1.2 轨迹识别` using `/Users/yoilun/Code/cold_display_guard` as the real project path | Active goal tracks lightweight trajectory detection plus YOLO-ready evidence contract | | 2026-05-29 | Setup | Main Agent | Read `/Users/yoilun/Code/goal-subagents-workflow-prompt.md` | Workflow requires task files, stage-based coding/testing agents, bug loop limit, and standard subagent context header | | 2026-05-29 | Setup | Main Agent | Read existing `task_plan.md`, `findings.md`, `progress.md`, and `docs/project.md` | v1.1 state is complete; v1.2 plan can start on branch `lightweight-trajectory-tracking` | +| 2026-05-29 | Phase 1 | Main Agent | Marked Phase 1 as `in_progress` | Preparing coding agent for data contract and engine evidence handling | +| 2026-05-29 | Phase 1 | Coding Agent | Implemented initial `disposal_evidence` contract and engine handling | Target engine tests and full Python tests passed in coding agent run, but testing agent found review issues | +| 2026-05-29 | Phase 1 | Testing Agent | Reviewed phase 1 implementation | Verdict fail: evidence/count double consumption, missing target validation, null classifier fields coercion | +| 2026-05-29 | Phase 1 | Coding Agent | Fixed testing-agent findings | Added target validation, nullable optional fields, and evidence/count double-consume guard | +| 2026-05-29 | Phase 1 | Testing Agent | Re-tested phase 1 fixes | Verdict pass; no bugs found | +| 2026-05-29 | Phase 1 | Main Agent | Ran local verification | `tests.test_engine` passed with 24 tests; full Python suite passed with 55 tests | ### Test Results | Time | Command | Result | Notes | | --- | --- | --- | --- | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_engine -v` | pass | 24 engine tests passed after phase 1 evidence fixes | +| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 55 full Python tests passed after phase 1 | ### Bug Loop | Phase | Bug | Fix Attempt | Retest Result | | --- | --- | --- | --- | +| Phase 1 | `disposal_evidence` and `trash_deposit_count` can double-consume the same disposal signal | Added regression test and suppress generic trash fallback when confirming source-specific evidence exists in the observation | Resolved; testing agent and local full Python suite passed | +| Phase 1 | High-confidence evidence with non-trash target can close pending disposal | Added target whitelist for `trash` / `trash_bin` plus regression test | Resolved; testing agent and local full Python suite passed | +| Phase 1 | `item_class: null` and `detector_score: null` lose null semantics | Changed optional evidence fields to preserve `None` plus regression test | Resolved; testing agent and local full Python suite passed | + +## 2026-05-29 Phase Completed: Phase 1 - Data Contract And Engine Evidence Handling + +Status: complete + +Files Changed: +- `src/cold_display_guard/models.py` +- `src/cold_display_guard/engine.py` +- `tests/test_engine.py` +- `task_plan.md` +- `progress.md` + +Tests: +- `PYTHONPATH=src python3 -m unittest tests.test_engine -v`: pass +- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass + +Notes: +- `Observation` now supports `disposal_evidence`. +- `BatchEngine` applies source-zone evidence before generic trash fallback and again after same-frame zone transitions. +- Evidence must meet confidence threshold and target the trash. + +Risks: +- Threshold is currently a phase-1 constant; later runtime config integration will make it configurable. diff --git a/src/cold_display_guard/engine.py b/src/cold_display_guard/engine.py index 77ac601..39f618d 100644 --- a/src/cold_display_guard/engine.py +++ b/src/cold_display_guard/engine.py @@ -3,7 +3,11 @@ from __future__ import annotations from datetime import datetime from typing import Any -from cold_display_guard.models import Batch, EngineSettings, Observation +from cold_display_guard.models import Batch, DisposalEvidence, EngineSettings, Observation + + +DISPOSAL_EVIDENCE_CONFIDENCE_THRESHOLD = 0.72 +TRASH_DISPOSAL_TARGETS = {"trash", "trash_bin"} class BatchEngine: @@ -20,8 +24,19 @@ class BatchEngine: zone_counts = self._normalized_counts(observation.zone_counts) previous_zone_counts = dict(self._zone_counts) remaining_trash_deposits = observation.trash_deposit_count + used_disposal_evidence: set[int] = set() + has_source_specific_disposal = self._has_confirming_disposal_evidence(observation.disposal_evidence) + if has_source_specific_disposal: + remaining_trash_deposits = 0 events.extend(self._expire_pending_disposal(observation.ts)) + events.extend( + self._apply_disposal_evidence( + observation.ts, + observation.disposal_evidence, + used_disposal_evidence, + ) + ) trash_events = self._apply_trash_deposits(observation.ts, remaining_trash_deposits) remaining_trash_deposits = max(0, remaining_trash_deposits - len(trash_events)) events.extend(trash_events) @@ -67,6 +82,13 @@ class BatchEngine: self._zone_counts[zone_id] = new_count newly_pending_count = max(0, len(self.pending_disposal) - pending_count_before_zone_transitions) + events.extend( + self._apply_disposal_evidence( + observation.ts, + observation.disposal_evidence, + used_disposal_evidence, + ) + ) trash_deposits_to_apply = remaining_trash_deposits if remaining_trash_deposits > 0 and newly_pending_count > 1: trash_deposits_to_apply = max(remaining_trash_deposits, newly_pending_count) @@ -239,6 +261,42 @@ class BatchEngine: deposit_count -= 1 return events + def _apply_disposal_evidence( + self, + when: datetime, + disposal_evidence: list[DisposalEvidence], + used_evidence_indices: set[int], + ) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for index, evidence in enumerate(disposal_evidence): + if index in used_evidence_indices: + continue + if not self._is_confirming_disposal_evidence(evidence): + continue + pending_index = self._pending_index_for_source_zone(evidence.source_zone_id) + if pending_index is None: + continue + batch = self.pending_disposal.pop(pending_index) + batch.state = "discarded" + self.closed_batches.append(batch) + used_evidence_indices.add(index) + events.append(self._event("batch_discarded", when, batch, severity="info")) + return events + + def _has_confirming_disposal_evidence(self, disposal_evidence: list[DisposalEvidence]) -> bool: + return any(self._is_confirming_disposal_evidence(evidence) for evidence in disposal_evidence) + + def _is_confirming_disposal_evidence(self, evidence: DisposalEvidence) -> bool: + if evidence.confidence < DISPOSAL_EVIDENCE_CONFIDENCE_THRESHOLD: + return False + return evidence.target.lower() in TRASH_DISPOSAL_TARGETS + + def _pending_index_for_source_zone(self, source_zone_id: str) -> int | None: + for index, batch in enumerate(self.pending_disposal): + if batch.zone_id == source_zone_id: + return index + return None + def _expire_pending_disposal(self, when: datetime) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] still_pending: list[Batch] = [] diff --git a/src/cold_display_guard/models.py b/src/cold_display_guard/models.py index e9e2c21..11cc44d 100644 --- a/src/cold_display_guard/models.py +++ b/src/cold_display_guard/models.py @@ -24,11 +24,39 @@ class EngineSettings: return timedelta(seconds=self.trash_confirmation_seconds) +@dataclass(frozen=True, slots=True) +class DisposalEvidence: + source_zone_id: str + target: str + confidence: float + method: str + track_points: list[Any] + item_class: str | None + detector_score: float | None + observed_at: str | None = None + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "DisposalEvidence": + return cls( + source_zone_id=str(payload.get("source_zone_id", "")).strip(), + target=str(payload.get("target", "")).strip(), + confidence=_float_or_zero(payload.get("confidence", 0.0)), + method=str(payload.get("method", "")).strip(), + track_points=_normalize_track_points(payload.get("track_points", [])), + item_class=_optional_string(payload.get("item_class")), + detector_score=_optional_float(payload.get("detector_score")), + observed_at=_optional_string( + payload.get("observed_at", payload.get("detected_at", payload.get("ts"))) + ), + ) + + @dataclass(frozen=True, slots=True) class Observation: ts: datetime zone_counts: dict[str, int] trash_deposit_count: int = 0 + disposal_evidence: list[DisposalEvidence] = field(default_factory=list) @classmethod def from_dict(cls, payload: dict[str, Any]) -> "Observation": @@ -46,6 +74,7 @@ class Observation: ts=ts, zone_counts={key: max(0, int(value)) for key, value in payload["zone_counts"].items()}, trash_deposit_count=max(0, trash_deposit_count), + disposal_evidence=_normalize_disposal_evidence(payload.get("disposal_evidence", [])), ) @@ -67,3 +96,47 @@ class Batch: if self.ended_at is not None: return self.dwell_seconds return max(0, int((when - self.started_at).total_seconds())) + + +def _normalize_disposal_evidence(raw_evidence: Any) -> list[DisposalEvidence]: + if raw_evidence is None: + return [] + if isinstance(raw_evidence, dict): + raw_items = [raw_evidence] + else: + raw_items = raw_evidence + return [ + DisposalEvidence.from_dict(item) + for item in raw_items + if isinstance(item, dict) + ] + + +def _normalize_track_points(raw_track_points: Any) -> list[Any]: + if isinstance(raw_track_points, list): + return list(raw_track_points) + if isinstance(raw_track_points, tuple): + return list(raw_track_points) + return [] + + +def _float_or_zero(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _optional_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _optional_string(value: Any) -> str | None: + if value is None: + return None + return str(value).strip() diff --git a/task_plan.md b/task_plan.md index a63d448..a52861c 100644 --- a/task_plan.md +++ b/task_plan.md @@ -96,7 +96,7 @@ Create and evolve an independent git project under `~/Code` for monitoring food | Phase | Status | Goal | Acceptance Criteria | | --- | --- | --- | --- | -| 1 | pending | 建立 `disposal_evidence` 数据契约并让状态机优先按来源区域丢弃 | `Observation` 支持 evidence;engine 能按 `source_zone_id` 精确关闭 pending batch;同帧移除+evidence 有回归测试;旧 `trash_deposit_count` 仍可兜底 | +| 1 | complete | 建立 `disposal_evidence` 数据契约并让状态机优先按来源区域丢弃 | `Observation` 支持 evidence;engine 能按 `source_zone_id` 精确关闭 pending batch;同帧移除+evidence 有回归测试;旧 `trash_deposit_count` 仍可兜底 | | 2 | pending | 实现无 YOLO 依赖的轻量轨迹检测 | synthetic frame 测试覆盖源区域到垃圾桶、非源区域运动、未到垃圾桶、单帧反光、多候选互不串扰;不引入模型依赖 | | 3 | pending | 集成 runtime 配置、诊断和候选窗口加速采样 | `main.py` 写入 `disposal_evidence` 与 trajectory diagnostics;配置默认 `trajectory_enabled=true`、`yolo_enabled=false`;候选活跃时使用更短采样间隔 | | 4 | pending | 文档、全量验证和部署准备 | README/project/progress 更新;Python 全量测试通过;前端测试/构建按影响范围验证;远端部署命令和风险记录清楚 | diff --git a/tests/test_engine.py b/tests/test_engine.py index 7db9948..525f2aa 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -9,16 +9,39 @@ from cold_display_guard import BatchEngine, EngineSettings, Observation UTC = timezone.utc -def obs(ts: datetime, counts: dict[str, int], trash: bool | int = False) -> Observation: +def obs( + ts: datetime, + counts: dict[str, int], + trash: bool | int = False, + disposal_evidence: list[dict[str, object]] | None = None, +) -> Observation: return Observation.from_dict( { "ts": ts.isoformat(), "zone_counts": counts, "trash_deposit": trash, + "disposal_evidence": disposal_evidence or [], } ) +def disposal_evidence( + source_zone_id: str, + confidence: float = 0.93, + target: str = "trash_bin", +) -> dict[str, object]: + return { + "source_zone_id": source_zone_id, + "target": target, + "confidence": confidence, + "method": "trajectory", + "track_points": [{"x": 101, "y": 202, "ts": "2026-04-27T10:20:01+00:00"}], + "item_class": "prepared_food", + "detector_score": 0.88, + "observed_at": "2026-04-27T10:20:02+00:00", + } + + class BatchEngineTests(unittest.TestCase): def setUp(self) -> None: self.settings = EngineSettings( @@ -80,6 +103,200 @@ class BatchEngineTests(unittest.TestCase): self.assertEqual([event["event"] for event in events], ["batch_discarded"]) + def test_observation_from_dict_normalizes_disposal_evidence(self) -> None: + observation = Observation.from_dict( + { + "ts": self.t0.isoformat(), + "zone_counts": {"1": "2"}, + "disposal_evidence": [ + { + "source_zone_id": 1, + "target": "trash_bin", + "confidence": "0.83", + "method": "trajectory", + "track_points": [{"x": 1, "y": 2}], + "item_class": "prepared_food", + "detector_score": "0.91", + "observed_at": "2026-04-27T10:00:01+00:00", + } + ], + } + ) + + evidence = observation.disposal_evidence[0] + self.assertEqual(evidence.source_zone_id, "1") + self.assertEqual(evidence.target, "trash_bin") + self.assertAlmostEqual(evidence.confidence, 0.83) + self.assertEqual(evidence.method, "trajectory") + self.assertEqual(evidence.track_points, [{"x": 1, "y": 2}]) + self.assertEqual(evidence.item_class, "prepared_food") + self.assertAlmostEqual(evidence.detector_score, 0.91) + self.assertEqual(evidence.observed_at, "2026-04-27T10:00:01+00:00") + + def test_observation_from_dict_preserves_null_optional_disposal_fields(self) -> None: + observation = Observation.from_dict( + { + "ts": self.t0.isoformat(), + "zone_counts": {"1": 1}, + "disposal_evidence": [ + { + "source_zone_id": "1", + "target": "trash_bin", + "confidence": 0.83, + "method": "trajectory", + "track_points": [], + "item_class": None, + "detector_score": None, + } + ], + } + ) + + evidence = observation.disposal_evidence[0] + self.assertIsNone(evidence.item_class) + self.assertIsNone(evidence.detector_score) + + def test_matching_disposal_evidence_discards_pending_batch(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0})) + + events = engine.process( + obs( + self.t0 + timedelta(seconds=1310), + {"1": 0}, + disposal_evidence=[disposal_evidence("1")], + ) + ) + + self.assertEqual([event["event"] for event in events], ["batch_discarded"]) + self.assertEqual(events[0]["zone_id"], "1") + self.assertEqual(engine.pending_disposal, []) + + def test_disposal_evidence_for_another_zone_does_not_discard_wrong_pending_batch(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1", "4"), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1, "4": 0})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1, "4": 0})) + engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0, "4": 0})) + + events = engine.process( + obs( + self.t0 + timedelta(seconds=1310), + {"1": 0, "4": 0}, + disposal_evidence=[disposal_evidence("4")], + ) + ) + + self.assertEqual(events, []) + self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"]) + + def test_non_trash_disposal_evidence_target_is_ignored(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0})) + + events = engine.process( + obs( + self.t0 + timedelta(seconds=1310), + {"1": 0}, + disposal_evidence=[disposal_evidence("1", target="customer_hand")], + ) + ) + + self.assertEqual(events, []) + self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"]) + + def test_disposal_evidence_and_trash_count_do_not_double_consume_same_signal(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1", "4"), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1, "4": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1, "4": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0, "4": 0})) + + events = engine.process( + obs( + self.t0 + timedelta(seconds=1310), + {"1": 0, "4": 0}, + trash=True, + disposal_evidence=[disposal_evidence("4")], + ) + ) + + self.assertEqual([(event["event"], event["zone_id"]) for event in events], [("batch_discarded", "4")]) + self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"]) + + def test_same_observation_removal_and_disposal_evidence_discards_newly_pending_batch(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1})) + + events = engine.process( + obs( + self.t0 + timedelta(seconds=1300), + {"1": 0}, + disposal_evidence=[disposal_evidence("1")], + ) + ) + later_events = engine.process(obs(self.t0 + timedelta(seconds=1421), {"1": 0})) + + self.assertEqual([event["event"] for event in events], ["batch_pending_disposal", "batch_discarded"]) + self.assertEqual(events[1]["zone_id"], "1") + self.assertEqual(later_events, []) + + def test_low_confidence_disposal_evidence_is_ignored(self) -> None: + settings = EngineSettings( + camera_id="test_cam", + max_dwell_seconds=1200, + trash_confirmation_seconds=120, + zone_ids=("1",), + ) + engine = BatchEngine(settings) + engine.process(obs(self.t0, {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1})) + engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0})) + + events = engine.process( + obs( + self.t0 + timedelta(seconds=1310), + {"1": 0}, + disposal_evidence=[disposal_evidence("1", confidence=0.71)], + ) + ) + + self.assertEqual(events, []) + self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"]) + def test_missing_trash_deposit_escalates_warning_after_deadline(self) -> None: self.engine.process(obs(self.t0, {"r1c1": 2})) self.engine.process(obs(self.t0 + timedelta(seconds=11), {"r1c1": 0}))