docs: adacam + adamaps codebase audit + gap analysis (2026-03-23)

This commit is contained in:
kayos 2026-03-23 21:47:41 -07:00
parent dd7db4fc6b
commit 3543b47ab8

View file

@ -0,0 +1,763 @@
# ADAMaps + AdaCam Codebase Audit
**Date:** 2026-03-23
**Repos:** `Sulkta-Coop/adacam`, `Sulkta-Coop/adamaps`
---
## Executive Summary
We've built a complete road sign detection and verification pipeline:
- **adacam** — Bee hardware tooling, liberation scripts, and a forwarder that reads detections from Hivemapper's local odc-api and sends them to our ADAMaps API
- **adamaps** — Flask API handling detection ingestion, sign clustering, agent verification (3-phase consensus), dedup voting, and weekly MAP token payouts via Cardano
**Key findings:**
1. **Missing data fields:** The forwarders don't capture `cam_heading`, `attributes` JSON (speed values, turn rules), `azimuth`, or camera position — data that's already in odc-api.db
2. **Two forwarder versions exist:** v1 reads SQLite directly, v2 reads REST — both are missing critical fields
3. **No systemd service deployed:** The forwarder service file exists but isn't actively running
4. **Payout system is production-ready:** Well-designed state machine with advisory locks, Koios integration, Matrix alerting
5. **Consensus engine is sound:** Advisory locks, deterministic tie-breakers, probation/tier gates
---
## 1. adacam Repo Structure
```
adacam/
├── README.md
├── api/
│ └── (API notes/docs)
├── docs/
│ ├── BEE_DATA_PIPELINE.md
│ ├── README.md
│ ├── SETTING_UP_TOOLING_README.md
│ ├── SYSTEMD_SERVICE_SETUP.md
│ ├── bee-liberation-guide.md
│ ├── dashcam-config-guide.md
│ ├── deploy-steps.md
│ └── hivemapper-odc-api-deep-dive-2026-03-23.md
├── keys/
│ ├── id_ed25519.pub
│ └── README.md
├── recon/
│ ├── config.json
│ ├── layout-tree.txt
│ ├── package.json
│ ├── ps-output.txt
│ ├── process_list.txt
│ ├── redis-keys.txt
│ ├── systemd-units.txt
│ └── units/
│ ├── camera.service
│ ├── framerate_log.service
│ ├── gnss.service
│ ├── map-ai.service
│ ├── odc-api.service
│ └── (others)
├── recovery/
│ └── liberate-v1.sh
├── scripts/
│ └── (build scripts, example bundles)
├── security/
│ └── security-posture-initial.md
└── services/
├── capture/
│ ├── adacam-forwarder.py # v1: reads odc-api.db directly
│ ├── adacam-forwarder-v2.py # v2: reads odc-api REST endpoints
│ ├── adacam-forwarder-v2.service
│ └── config.json
├── updater/
│ └── (auto-update scripts)
└── wigle/
└── (WiFi scanning integration)
```
---
## 2. adacam-forwarder Analysis
### 2a. v1: `adacam-forwarder.py` (SQLite Direct)
**Location:** `services/capture/adacam-forwarder.py`
**What it reads:**
```sql
SELECT id, ts, image_name, class_label, confidence, lat, lon
FROM landmarks
WHERE id > ?
AND negative_class = 0
AND confidence >= ?
AND lat IS NOT NULL AND lon IS NOT NULL
AND lat != 0 AND lon != 0
ORDER BY id ASC
LIMIT ?
```
**Cursor strategy:** Tracks `last_detection_id` in `/data/adacam/forwarder_state.json`, queries `WHERE id > last_id`. Correct approach — matches odc-api's `fetchLandmarksFromId`.
**What it sends to ADAMaps:**
```python
{
'id': str(row['id']),
'ts': int(row['ts']),
'lat': float(row['lat']),
'lon': float(row['lon']),
'class_label': row['class_label'] or 'unknown',
'overall_confidence': float(row['confidence']),
'device_id': config['device_id'],
}
```
**Image upload:** Yes — fetches from `/data/recording/cached_observations/{image_name}` and POSTs to `/api/images`.
### 2b. v2: `adacam-forwarder-v2.py` (REST API)
**Endpoints tried (in order):**
- `/api/landmarks`
- `/landmarks`
**Fetch logic:**
```python
url = f'{odc_url}/api/landmarks/from/{from_id}' # if from_id > 0
url = f'{odc_url}/api/landmarks/n/{n}' # first run (bootstrap)
```
**What it sends:** Same as v1, but via REST response parsing instead of SQLite.
**Image upload:** No — v2 only forwards detection metadata, no image upload.
**GPS fallback:** Queries Redis `GNSSFusion30Hz` zset if landmark has no GPS coords.
### 2c. Missing Fields (Both Versions)
| odc-api.db Column | v1 Captures | v2 Captures | Impact |
|-------------------|-------------|-------------|--------|
| `id` | ✅ | ✅ | cursor key |
| `ts` | ✅ | ✅ | timestamp |
| `class_label` | ✅ | ✅ | sign type |
| `confidence` | ✅ | ✅ | detection confidence |
| `lat`, `lon` | ✅ | ✅ | sign GPS position |
| `image_name` | ✅ | ❌ | image upload |
| `cam_lat`, `cam_lon` | ❌ | ❌ | **camera position at detection** |
| `cam_heading` | ❌ | ❌ | **vehicle heading — sign facing** |
| `attributes` | ❌ | ❌ | **JSON with `speed_label`, `turn_rule`** |
| `azimuth` | ❌ | ❌ | estimated sign azimuth |
| `width`, `height` | ❌ | ❌ | estimated physical size |
| `overall_confidence` | ❌ | ❌ | fused confidence |
| `map_feature_id` | ❌ | ❌ | FK to map_features |
| `x1/y1/x2/y2` | ❌ | ❌ | bounding box coords |
**Critical miss:** The `attributes` JSON contains `{"speed_label": 25, "speed_label_conf": 0.99}` — Hivemapper's model already reads speed values from signs. We're manually doing Phase 2 agent verification to read sign text when the ML model already extracts it.
### 2d. Systemd Service
**File exists:** `services/capture/adacam-forwarder-v2.service`
```ini
[Unit]
Description=AdaCam Forwarder v2
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/adacam
ExecStart=/usr/bin/python3 /opt/adacam/services/adacam-forwarder-v2.py
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
```
**Status:** Not deployed. The service file is in the repo but hasn't been symlinked to `/etc/systemd/system/` on the Bee.
---
## 3. adamaps Repo Structure
```
adamaps/
├── README.md
├── Dockerfile
├── app.py # Main Flask API
├── payout.py # Weekly MAP payout system
├── init.sql # Base schema (detections table)
├── api/
│ └── app.py # (Duplicate? Check if this is the live one)
├── bee/
│ └── (Bee-specific tooling)
├── cardano/
│ └── payout_treasury.sh
├── db/
│ ├── migration_001_payout.sql
│ ├── migration_002_sybil.sql
│ └── migration_003_dedup.sql
├── docs/
│ └── (architecture docs)
├── rackham/
│ └── (deployment configs)
└── web/
└── html/
└── (frontend assets)
```
---
## 4. adamaps Flask API (`app.py`)
### 4a. Configuration
```python
CLUSTER_RADIUS_M = 40 # Detections within 40m → same sign
CLUSTER_MIN_CONFIDENCE = 0.30 # Min confidence to create/update sign
CONSENSUS_THRESHOLD = 3 # 3 agreeing agents = consensus
MIN_ADA_STAKE_LOVELACE = 5_000_000 # 5 ADA minimum to register
GROUNDTRUTH_PASS_THRESHOLD = 3 # 3/5 correct to pass ground truth
```
### 4b. All Endpoints
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/api/health` | None | Health check |
| POST | `/api/ingest` | `X-AdaMaps-Key` | Detection ingestion from forwarder |
| POST | `/api/images` | `X-AdaMaps-Key` | Image upload |
| GET | `/api/images/<filename>` | Rate limited | Serve image (with `?crop=sign_id` support) |
| GET | `/api/images/<filename>/crop` | Rate limited | Serve cropped image |
| GET | `/api/detections` | Read key | List recent detections |
| GET | `/api/signs` | Read key | List clustered signs |
| GET | `/api/signs/tasks` | Read key | Agent training task feed |
| GET | `/api/stats` | None | System statistics |
| GET | `/api/agent/info` | None | Agent API documentation |
| POST | `/api/agent/challenge` | None | Request wallet auth nonce |
| POST | `/api/agent/register` | None | Register agent with wallet sig |
| GET | `/api/agent/me` | `X-Agent-Key` | Authenticated agent profile |
| GET | `/api/agent/groundtruth` | `X-Agent-Key` | Get ground truth test (5 signs) |
| POST | `/api/agent/groundtruth/submit` | `X-Agent-Key` | Submit ground truth answers |
| GET | `/api/agent/status` | `X-Agent-Key` | Full agent status with rank |
| GET | `/api/agent/consensus/<sign_id>` | `X-Agent-Key` | Consensus status per sign |
| GET | `/api/agent/leaderboard` | None | Public leaderboard |
| POST | `/api/agent/rotate-key` | `X-Agent-Key` | Rotate API key |
| GET | `/api/agent/tasks` | `X-Agent-Key` | Get available tasks (phase 1 or 2) |
| POST | `/api/agent/claim/<sign_id>` | `X-Agent-Key` | Claim a task |
| POST | `/api/agent/submit` | `X-Agent-Key` | Submit task result |
| GET | `/api/signs/dedup-tasks` | Read key | Get dedup voting pairs |
| POST | `/api/agent/dedup/vote` | `X-Agent-Key` | Vote on sign merge |
| GET | `/api/agent/dedup/status` | Read key | Dedup pair status |
| POST | `/api/admin/payouts/trigger` | `X-AdaMaps-Key` | Manually trigger payout |
| GET | `/api/admin/payouts/status` | `X-AdaMaps-Key` | Payout system status |
| POST | `/api/admin/signs/recalibrate` | `X-AdaMaps-Key` | Backfill sign_id + recalc accuracy |
### 4c. Ingest Pipeline
1. **Detection arrives** via POST `/api/ingest` with `{device_id, detections: [...]}`
2. **Each detection inserted** into `detections` table with PostGIS point geometry
3. **Sign refinement** via `_update_sign_from_detection()`:
- Find nearest sign of same type within 40m
- If found: update confidence-weighted centroid, increment observation count
- If not found and confidence ≥ 0.3: create new sign
4. **Detection linked** to sign via `sign_id` FK
### 4d. Agent Verification System
**Phases:**
1. **Phase 1 — Type verification:** "What type of sign is this?" (stop-sign, speed-limit, etc.)
2. **Phase 2 — Text reading:** "What does the sign say?" (the actual text/numbers)
**Tiers:**
- `probation` — New agents, must pass ground truth test (3/5 correct)
- `standard` — Passed ground truth, can do Phase 1 tasks
- `trusted` — Rep score ≥ 60, can do Phase 2 tasks
- `expert` — Rep score ≥ 80, weighted higher in consensus
**Ground truth:** 5 random oracle-verified signs shown to agent, must get 3/5 correct to graduate from probation.
**Consensus engine (`check_consensus`):**
1. Advisory lock on `(sign_id, phase)` prevents race conditions
2. Group submissions by `(assessment, normalized_text)`
3. Require 3+ agents with at least 2 non-probation to reach consensus
4. Deterministic tie-breaker: more agents → higher avg confidence → lexicographic assessment
5. Oracle fast-path: oracle submission = instant consensus
**Rewards (MAP tokens):**
- Phase 1 consensus: 0.5 MAP total / split among agreeing agents
- Phase 2 consensus: 1.0 MAP total / split among agreeing agents
- Dedup vote: 0.125 MAP per vote
- Dedup consensus: 0.25 MAP bonus
**Reputation:**
- Agreed with consensus: +2 rep
- Disagreed: -1 rep
- Honest "cannot_identify" on ambiguous sign: +0.5 rep
- Wrong "cannot_identify" on identifiable sign: -0.5 rep
### 4e. Dedup System
**Candidate pairs query:** Signs of same type within 25m, or different types within 10m, both with images, not already merged or dismissed.
**Voting:** Agents vote "same" or "different" with confidence (0.0-1.0).
**Consensus:** 2+ votes in same direction wins. Winner is sign with higher observation count (tie-break: lower ID).
**Merge action:** Loser's `merged_into` set to winner ID, detections re-pointed to winner.
### 4f. Image Serving + Cropping
**Full image:** `GET /api/images/{filename}`
**Cropped sign:** `GET /api/images/{filename}?crop={sign_id}` — uses bbox from `detections.raw_json->bbox` or `bbox_x1/y1/x2/y2` columns, crops with 15-20px padding via PIL.
**Current state:** Cropping works but bbox coords aren't being forwarded by the forwarder (missing `x1/y1/x2/y2`).
### 4g. Rate Limiting
```python
limiter = Limiter(key_func=get_rate_limit_key)
# Separate buckets for admin vs read keys
def get_rate_limit_key():
key = request.headers.get("X-AdaMaps-Key", "")
if key == API_KEY: return "trusted:admin"
if key == READ_KEY: return "trusted:read"
return get_remote_address()
```
- `/api/images/<filename>`: 120/min
- `/api/detections`: 60/min
- `/api/signs`: 60/min
- Ground truth endpoints: 10/hour or 5/hour
---
## 5. Payout System (`payout.py`)
### 5a. State Machine
```
building → built → submitted → confirmed
↓ ↓ ↓
failed failed failed
retried (earnings unlinked for next batch)
```
### 5b. Key Design Patterns
**Advisory lock:** `pg_try_advisory_lock(98765)` ensures only one worker runs payouts.
**Two-connection pattern:**
```python
with db.engine.connect() as lock_conn:
# lock_conn holds the advisory lock
locked = lock_conn.execute("SELECT pg_try_advisory_lock(98765)").scalar()
with SASession(db.engine) as session:
# session runs on separate pool connection
_run_weekly_payout_locked(session)
```
**Address validation:** `validate_payout_address()` checks bech32 decoding before batch creation — invalid addresses excluded upfront, not mid-transaction.
**Stuck batch detection:** `alert_stuck_batches()` finds batches stuck in `building` or `built` for > 5 min, auto-fails `building` batches to unlink frozen earnings.
**Idempotent submit:** If `submit_transaction()` times out, checks if tx landed on-chain via Koios before marking failed.
### 5c. Koios Integration
**Balance check:** POST `/api/v1/address_info` with hot wallet address, parses `utxo_set[].asset_list` for MAP tokens.
**Confirmation check:** POST `/api/v1/tx_info` with tx hash, returns slot and block hash.
**Alert on unconfirmed:** If submitted batch unconfirmed for > 24 hours, sends Matrix alert.
### 5d. Scheduler
```python
scheduler.add_job(
func=_payout_job_wrapper,
trigger=CronTrigger(day_of_week='mon', hour=10, minute=0, timezone='UTC'),
id='weekly_payout',
)
```
File lock `/tmp/adamaps_scheduler.lock` ensures only one Gunicorn worker runs the scheduler.
---
## 6. Database Schema
### 6a. Core Tables
**`detections`** — Raw detection events from forwarder:
```sql
id SERIAL PRIMARY KEY
device_id TEXT NOT NULL
detected_at TIMESTAMPTZ NOT NULL
lat, lon DOUBLE PRECISION NOT NULL
geom GEOMETRY(Point, 4326) GENERATED
sign_type TEXT
confidence DOUBLE PRECISION
image_path VARCHAR
bbox_x1, bbox_y1, bbox_x2, bbox_y2 FLOAT
raw_json JSONB
sign_id INTEGER REFERENCES signs(id) -- added via migration
```
**`signs`** — Clustered/deduplicated sign positions:
```sql
id SERIAL PRIMARY KEY
lat, lon DOUBLE PRECISION
sign_type TEXT
sign_text TEXT -- from Phase 2 consensus
heading DOUBLE PRECISION
observation_count INTEGER
avg_confidence DOUBLE PRECISION
confidence_weight DOUBLE PRECISION
device_count INTEGER
first_seen, last_seen TIMESTAMPTZ
verified BOOLEAN
agent_verified BOOLEAN -- from consensus engine
location_accuracy_m FLOAT
merged_into INTEGER -- dedup: points to winner
merge_dismissed BOOLEAN -- dedup: dismissed pair
image_name TEXT
```
**`agent_registry`** — Registered agents:
```sql
id SERIAL PRIMARY KEY
agent_id VARCHAR(32) UNIQUE -- "agt_" + blake2b(pubkey)
cardano_address VARCHAR(128)
public_key_hex VARCHAR(64)
api_key_hash VARCHAR(64) -- SHA256 of api key
tier VARCHAR(20) -- probation/standard/trusted/expert
reputation_score FLOAT
total_submissions INTEGER
consensus_agreements INTEGER
map_earned DOUBLE PRECISION
groundtruth_passed BOOLEAN
is_oracle BOOLEAN
```
**`agent_submissions`** — Task submissions:
```sql
sign_id INTEGER
phase INTEGER -- 1 or 2
agent_id VARCHAR(32)
assessment VARCHAR(128)
sign_text TEXT -- Phase 2 only
confidence FLOAT
in_consensus BOOLEAN -- TRUE if agreed with final consensus
```
**`task_consensus`** — Finalized consensus:
```sql
sign_id INTEGER
phase INTEGER
agreed_assessment VARCHAR
agreed_text TEXT
consensus_count INTEGER
total_submissions INTEGER
reward_per_agent FLOAT
```
**`map_earnings`** — Individual earning events:
```sql
agent_id INTEGER REFERENCES agent_registry(id)
amount BIGINT -- raw MAP (6 decimals)
reason VARCHAR(100) -- 'consensus', 'dedup_vote', etc.
sign_id INTEGER
phase INTEGER
payout_item_id INTEGER -- linked when batch created
paid_at TIMESTAMPTZ -- set on confirmation
```
**`payout_batches`** — Batch transaction state:
```sql
status VARCHAR(20) -- building/built/submitted/confirmed/failed/retried
tx_hash VARCHAR(64)
tx_cbor TEXT -- stored for resubmit
confirmed_slot BIGINT
total_map_raw BIGINT
output_count INTEGER
retry_count INTEGER
```
### 6b. Indexes
```sql
-- Spatial
CREATE INDEX detections_geom_idx ON detections USING GIST(geom);
-- Earnings lookup
CREATE INDEX idx_map_earnings_unpaid ON map_earnings(agent_id) WHERE payout_item_id IS NULL;
-- Agent submissions
CREATE INDEX idx_agent_submissions_sign_phase ON agent_submissions(sign_id, phase);
```
---
## 7. Overlap Analysis: adacam-forwarder vs odc-api LandmarkProxyService
### 7a. What They Do
**odc-api LandmarkProxyService:**
- Runs every 5 minutes on the Bee
- Uploads up to 1000 landmarks per batch to `PUT hivemapper.com/api/plugins/auditMultiplePlugins`
- Filters by confidence ≥ 0.6
- Transforms some classes to `generic-sign`
- Includes full payload with `attributes`, `azimuth`, `width/height`
**adacam-forwarder:**
- Runs every 30 seconds
- Uploads to our ADAMaps API
- Filters by confidence ≥ 0.3
- Doesn't transform classes
- **Missing:** `attributes`, `azimuth`, camera position, bbox coords
### 7b. Conflict?
No direct conflict — they run in parallel. LandmarkProxyService uploads to Hivemapper (blocked by our network config), adacam-forwarder uploads to ADAMaps.
**But:** LandmarkProxyService is dead weight CPU consumption. Should kill it in a liberated Bee.
### 7c. Data Fields Comparison
| Field | odc-api captures | forwarder sends | ADAMaps stores |
|-------|------------------|-----------------|----------------|
| id | ✅ | ✅ | ✅ (in raw_json) |
| ts | ✅ | ✅ | ✅ detected_at |
| lat/lon | ✅ | ✅ | ✅ |
| class_label | ✅ | ✅ | ✅ sign_type |
| confidence | ✅ | ✅ | ✅ |
| image_name | ✅ | ✅ (v1) | ✅ image_path |
| x1/y1/x2/y2 | ✅ | ❌ | ❓ (bbox_* columns exist) |
| cam_lat/lon | ✅ | ❌ | ❌ |
| cam_heading | ✅ | ❌ | ❌ (heading column exists) |
| attributes | ✅ | ❌ | ❌ |
| azimuth | ✅ | ❌ | ❌ |
| width/height | ✅ | ❌ | ❌ |
---
## 8. Gap Analysis
### 8a. Forwarder Gaps
| Issue | Severity | Impact |
|-------|----------|--------|
| Missing `attributes` JSON | 🔴 High | Speed sign values already extracted by ML — we're redoing Phase 2 manually |
| Missing `cam_heading` | 🟡 Medium | Can't determine sign facing direction |
| Missing `x1/y1/x2/y2` bbox | 🟡 Medium | Crop endpoint can't work without detection-level bbox |
| No systemd service running | 🟡 Medium | Forwarder not actually deployed |
| v2 doesn't upload images | 🟠 Medium-Low | v1 does, v2 skips — which is deployed? |
### 8b. API Gaps
| Issue | Severity | Impact |
|-------|----------|--------|
| Crop endpoint relies on `raw_json->bbox` | 🟡 Medium | Works only if forwarder sends bbox in raw_json |
| No `attributes` column in detections | 🟡 Medium | Can't store speed values even if forwarder sent them |
| Phase 2 could be partially automated | 🟡 Medium | If we had `attributes`, fewer tasks needed |
### 8c. Database Gaps
| Issue | Severity | Impact |
|-------|----------|--------|
| No `attributes` JSONB column on detections | 🟡 Medium | Would need migration |
| No `cam_heading` on detections | 🟡 Medium | Would need migration |
| Indexes on signs look OK | ✅ | |
| No GIST index on signs.geom | 🟠 Low | Only detections has spatial index |
---
## 9. Improvement Recommendations
### 9a. Top 3 Highest-Impact Forwarder Improvements
1. **Add `attributes` JSON forwarding**
```python
# In SQL query:
SELECT id, ts, lat, lon, class_label, confidence, image_name,
cam_lat, cam_lon, cam_heading, attributes,
x1, y1, x2, y2
FROM landmarks WHERE ...
```
Then include in payload:
```python
'attributes': row['attributes'], # JSON as-is
'bbox_x1': row['x1'], 'bbox_y1': row['y1'],
'bbox_x2': row['x2'], 'bbox_y2': row['y2'],
'cam_heading': row['cam_heading'],
```
**Why:** Hivemapper's model already extracts `speed_label` — this data exists, we're just not forwarding it.
2. **Deploy systemd service**
```bash
sudo cp adacam-forwarder-v2.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now adacam-forwarder-v2
```
With watchdog support:
```ini
WatchdogSec=60
NotifyAccess=all
```
**Why:** Forwarder needs to actually run persistently.
3. **Use v1's SQLite approach with v2's robustness**
- SQLite is lower latency than REST
- Add v2's retry queue to v1
- Add v2's Redis GPS fallback to v1
**Why:** Best of both worlds — direct DB access with fault tolerance.
### 9b. Top 3 Highest-Impact API Improvements
1. **Add `attributes` column and ingest it**
```sql
ALTER TABLE detections ADD COLUMN attributes JSONB;
```
In `/api/ingest`:
```python
cur.execute("""
INSERT INTO detections (..., attributes, bbox_x1, bbox_y1, bbox_x2, bbox_y2)
VALUES (..., %s, %s, %s, %s, %s)
""", (..., d.get('attributes'), d.get('bbox_x1'), ...))
```
Then in Phase 2 task feed, check if `attributes.speed_label` exists — if so, pre-populate expected answer.
**Why:** Reduces agent task load for speed signs by ~90%.
2. **Auto-verify signs with high-confidence `attributes`**
```python
if attributes and attributes.get('speed_label_conf', 0) > 0.95:
# Auto-complete Phase 2 for this sign
sign_text = f"SPEED LIMIT {attributes['speed_label']}"
```
**Why:** ML is already doing the work — trust it for high-confidence cases.
3. **Add GIST index on signs**
```sql
ALTER TABLE signs ADD COLUMN geom GEOMETRY(Point, 4326)
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(lon, lat), 4326)) STORED;
CREATE INDEX signs_geom_idx ON signs USING GIST(geom);
```
**Why:** Spatial queries on signs table are slow without it.
### 9c. Strategic Questions
**Should we use odc-api REST endpoints or keep reading SQLite directly?**
**Recommendation: Keep SQLite.**
- Lower latency (no HTTP overhead)
- No missing detections if odc-api service crashes
- No class filtering we don't want
- Direct access to all columns
**Should we adopt the `attributes` JSON field?**
**Recommendation: Yes, immediately.**
This is the single highest-impact change. The data exists, the ML model extracts it, we're just not using it.
**Should the forwarder send bbox crops instead of full frames?**
**Recommendation: No — keep full frames.**
- odc-api's `/landmarks/:id/chips/:chip_id` endpoint uses Jimp on-device
- Adds HTTP overhead per detection
- We can crop server-side (already doing it)
- Full frames allow re-cropping if bbox was wrong
**Systemd service design:**
```ini
[Unit]
Description=AdaCam Forwarder
After=network-online.target odc-api.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=root
WorkingDirectory=/opt/adacam
ExecStart=/usr/bin/python3 /opt/adacam/services/adacam-forwarder.py
Restart=always
RestartSec=30
WatchdogSec=120
StandardOutput=journal
StandardError=journal
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/data/adacam
[Install]
WantedBy=multi-user.target
```
---
## 10. Recommended Next Steps
### Immediate (This Week)
1. **Update forwarder SQL query** to include `cam_heading`, `attributes`, and bbox coords
2. **Add `attributes` JSONB column** to `detections` table
3. **Deploy forwarder systemd service** on the Bee
4. **Kill LandmarkProxyService** (blocked anyway, but wasting CPU)
### Short-Term (This Month)
5. **Auto-verify speed signs** when `attributes.speed_label_conf > 0.95`
6. **Backfill existing signs** with speed values from `attributes`
7. **Add signs GIST index** for faster spatial queries
### Medium-Term
8. **Consider sign heading** from `cam_heading` + `azimuth` to determine which direction the sign faces
9. **Phase 2 task prioritization** — skip signs that already have high-confidence `attributes`
10. **Monitoring dashboard** for forwarder health (last sync time, queue depth, error rate)
---
## Appendix A: File Hashes
Key files for reference:
| File | Lines | Last Modified |
|------|-------|---------------|
| `adacam/services/capture/adacam-forwarder.py` | ~280 | 2026-03-xx |
| `adacam/services/capture/adacam-forwarder-v2.py` | ~230 | 2026-03-xx |
| `adamaps/api/app.py` | ~1800 | 2026-03-22 |
| `adamaps/payout.py` | ~500 | 2026-03-xx |
## Appendix B: odc-api Landmarks Schema (from deep dive)
Full schema of Hivemapper's `landmarks` table in odc-api.db:
```sql
CREATE TABLE landmarks (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
image_name TEXT,
class_label TEXT,
class_label_confidence REAL,
overall_confidence REAL,
x1 INTEGER, y1 INTEGER, x2 INTEGER, y2 INTEGER, -- bbox
lat REAL, lon REAL, alt REAL, -- sign position
cam_lat REAL, cam_lon REAL, cam_heading REAL, -- camera position
azimuth REAL,
width REAL, height REAL,
negative_class INTEGER DEFAULT 0,
confidence REAL,
pos_confidence REAL,
attributes TEXT, -- JSON: {"speed_label": 25, "speed_label_conf": 0.99}
track_id TEXT,
map_feature_id INTEGER,
model_id TEXT
);
```
---
*End of audit.*