feat: happiness scoring pipeline + ESP32 swarm with Cognitum Seed (#285)

* feat: happiness scoring pipeline with ESP32 swarm + Cognitum Seed coordinator

ADR-065: Hotel guest happiness scoring from WiFi CSI physiological proxies.
ADR-066: ESP32 swarm with Cognitum Seed as coordinator for multi-zone analytics.

Firmware:
- swarm_bridge.c/h: FreeRTOS task on Core 0, HTTP client with Bearer auth,
  registers with Seed, sends heartbeats (30s) and happiness vectors (5s)
- nvs_config: seed_url, seed_token, zone_name, swarm intervals
- provision.py: --seed-url, --seed-token, --zone CLI args
- esp32-hello-world: capability discovery firmware for 4MB ESP32-S3 variant

WASM edge modules:
- exo_happiness_score.rs: 8-dim happiness vector from gait speed, stride
  regularity, movement fluidity, breathing calm, posture, dwell time
  (events 690-694, 11 tests, ESP32-optimized buffers + event decimation)
- ghost_hunter.rs standalone binary: 5.7 KB WASM, feature-gated default pipeline

RuView Live:
- --mode happiness dashboard with bar visualization
- --seed flag for Cognitum Seed bridge (urllib, background POST)
- HappinessScorer + SeedBridge classes (stdlib only, no deps)

Examples:
- seed_query.py: CLI tool (status, search, witness, monitor, report)
- provision_swarm.sh: batch provisioning for multi-node deployment
- happiness_vector_schema.json: 8-dim vector format documentation

Verified live: ESP32 on COM5 (4MB flash) registered with Seed at 10.1.10.236,
vectors flowing, witness chain growing (epoch 455, chain 1108).

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci: raise firmware binary size gate to 1100 KB for HTTP client stack

The swarm bridge (ADR-066) adds esp_http_client for Seed communication,
which pulls in the HTTP/TLS stack (~150 KB). Binary grew from ~978 KB to
~1077 KB. Raise the gate from 950 KB to 1100 KB. Still fits comfortably
in both 4MB (1856 KB OTA slot, 43% free) and 8MB flash variants.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv
2026-03-20 18:46:34 -04:00
committed by GitHub
parent 8a84748a83
commit 2b8a7cc458
22 changed files with 3071 additions and 39 deletions
@@ -0,0 +1,99 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Happiness Vector",
"description": "8-dimensional happiness feature vector for Cognitum Seed ingestion (ADR-065). Each dimension is normalized to [0, 1] where higher values indicate more positive affect.",
"type": "object",
"properties": {
"vectors": {
"type": "array",
"items": {
"type": "array",
"prefixItems": [
{
"type": "integer",
"description": "Vector ID: node_id * 1000000 + type_offset + timestamp_component. Type offsets: 0=registration, 100000=heartbeat, 200000=happiness."
},
{
"type": "array",
"items": { "type": "number", "minimum": 0, "maximum": 1 },
"minItems": 8,
"maxItems": 8,
"description": "8-dim happiness vector: [happiness_score, gait_speed, stride_regularity, movement_fluidity, breathing_calm, posture_score, dwell_factor, social_energy]"
}
],
"minItems": 2,
"maxItems": 2
}
}
},
"required": ["vectors"],
"$defs": {
"dimensions": {
"type": "object",
"description": "Happiness vector dimension definitions",
"properties": {
"dim_0_happiness_score": {
"description": "Composite happiness [0=sad, 0.5=neutral, 1=happy]. Weighted sum of dims 1-6.",
"weights": "gait=0.25, stride=0.15, fluidity=0.20, calm=0.20, posture=0.10, dwell=0.10"
},
"dim_1_gait_speed": {
"description": "Walking speed from CSI phase rate-of-change. Happy people walk ~12% faster.",
"source": "Phase Doppler shift",
"units": "normalized phase delta / MAX_GAIT_SPEED"
},
"dim_2_stride_regularity": {
"description": "Step interval consistency. Regular strides indicate confidence/positive affect.",
"source": "Variance coefficient of step intervals (inverted)",
"interpretation": "1.0=perfectly regular, 0.0=erratic/stumbling"
},
"dim_3_movement_fluidity": {
"description": "Smoothness of body movement trajectory. Jerky motion indicates anxiety.",
"source": "Phase second derivative (acceleration), inverted",
"interpretation": "1.0=smooth/flowing, 0.0=jerky/hesitant"
},
"dim_4_breathing_calm": {
"description": "Breathing rate mapped to calmness. Slow deep breathing = relaxed.",
"source": "0.15-0.5 Hz phase oscillation (breathing proxy)",
"interpretation": "1.0=calm (6-14 BPM), 0.0=rapid/stressed (>22 BPM)"
},
"dim_5_posture_score": {
"description": "Upright vs slouched posture from RF scattering cross-section.",
"source": "Amplitude coefficient of variation across subcarrier groups",
"interpretation": "1.0=upright (wide spread), 0.0=slouched (narrow spread)"
},
"dim_6_dwell_factor": {
"description": "How long the person stays in the sensing zone.",
"source": "Fraction of recent frames with presence detected",
"interpretation": "1.0=lingering (happy guests browse), 0.0=rushing through"
},
"dim_7_social_energy": {
"description": "Group animation and interaction level.",
"source": "Motion energy + dwell + heart rate proxy",
"interpretation": "1.0=animated group interaction, 0.0=solitary/withdrawn"
}
}
},
"event_ids": {
"type": "object",
"description": "WASM edge module event IDs (690-694)",
"properties": {
"690_HAPPINESS_SCORE": "Composite happiness [0, 1] — emitted every frame",
"691_GAIT_ENERGY": "Gait speed + stride regularity composite — emitted every 4th frame",
"692_AFFECT_VALENCE": "Breathing calm + fluidity + posture composite — emitted every 4th frame",
"693_SOCIAL_ENERGY": "Group animation level — emitted every 4th frame",
"694_TRANSIT_DIRECTION": "1.0=entering, 0.0=exiting — emitted every 4th frame"
}
},
"seed_id_scheme": {
"type": "object",
"description": "Vector ID encoding for Cognitum Seed",
"properties": {
"format": "node_id * 1000000 + type_offset + timestamp_component",
"registration": "offset 0 (e.g. node 1 = 1000000)",
"heartbeat": "offset 100000 + uptime_sec % 100000 (e.g. 1100042)",
"happiness": "offset 200000 + ms_timestamp / 1000 % 100000 (e.g. 1212345)"
}
}
}
}
@@ -0,0 +1,60 @@
#!/bin/bash
# ESP32 Swarm Provisioning — ADR-065/066
#
# Provisions multiple ESP32-S3 nodes for a hotel happiness sensing deployment.
# Each node gets WiFi credentials, a unique node_id, zone name, and Seed token.
#
# Prerequisites:
# - ESP-IDF Python venv with esptool and nvs_partition_gen
# - Firmware already flashed to each ESP32
# - Seed paired (obtain token via: curl -X POST http://169.254.42.1/api/v1/pair)
#
# Usage:
# bash provision_swarm.sh
set -euo pipefail
# ---- Configuration ----
SSID="RedCloverWifi"
PASSWORD="redclover2.4"
SEED_URL="http://10.1.10.236"
SEED_TOKEN="hyHVY4Ux6uBAh8FaQzF_9OwWCWMFB-YuM2OJ3Dcwdm8" # Replace with your token
PROVISION="../../firmware/esp32-csi-node/provision.py"
# ---- Node definitions: PORT NODE_ID ZONE ----
NODES=(
"COM5 1 lobby"
"COM6 2 hallway"
"COM8 3 restaurant"
"COM9 4 pool"
"COM10 5 conference"
)
echo "========================================"
echo " ESP32 Swarm Provisioning"
echo " Seed: $SEED_URL"
echo " WiFi: $SSID"
echo " Nodes: ${#NODES[@]}"
echo "========================================"
echo
for entry in "${NODES[@]}"; do
read -r port node_id zone <<< "$entry"
echo "--- Node $node_id: $zone ($port) ---"
python "$PROVISION" \
--port "$port" \
--ssid "$SSID" \
--password "$PASSWORD" \
--node-id "$node_id" \
--seed-url "$SEED_URL" \
--seed-token "$SEED_TOKEN" \
--zone "$zone" \
&& echo " OK" || echo " FAILED (device not connected?)"
echo
done
echo "========================================"
echo " Provisioning complete."
echo " Monitor with: python seed_query.py monitor --seed $SEED_URL --token $SEED_TOKEN"
echo "========================================"
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
Cognitum Seed — Happiness Vector Query Tool
Query the Seed's vector store for happiness patterns across ESP32 swarm nodes.
Demonstrates kNN search, drift monitoring, and witness chain verification.
Usage:
python seed_query.py --seed http://10.1.10.236 --token <bearer_token>
python seed_query.py --seed http://169.254.42.1 # USB link-local (no token needed)
Requirements:
Python 3.7+ (stdlib only, no dependencies)
"""
import argparse
import json
import sys
import time
import urllib.request
import urllib.error
def api(base, path, token=None, method="GET", data=None):
"""Make an API request to the Seed."""
url = f"{base}{path}"
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"error": f"HTTP {e.code}", "detail": e.read().decode()[:200]}
except Exception as e:
return {"error": str(e)}
def print_header(title):
print(f"\n{'=' * 60}")
print(f" {title}")
print(f"{'=' * 60}")
def cmd_status(args):
"""Show Seed and swarm status."""
print_header("Seed Status")
s = api(args.seed, "/api/v1/status", args.token)
if "error" in s:
print(f" Error: {s['error']}")
return
print(f" Device: {s['device_id'][:8]}...")
print(f" Vectors: {s['total_vectors']} (dim={s['dimension']})")
print(f" Epoch: {s['epoch']}")
print(f" Store: {s['file_size_bytes'] / 1024:.1f} KB")
print(f" Uptime: {s['uptime_secs'] // 3600}h {(s['uptime_secs'] % 3600) // 60}m")
print(f" Witness: {s['witness_chain_length']} entries")
print_header("Drift Detection")
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
if "error" not in d:
print(f" Drifting: {d.get('drifting', False)}")
print(f" Score: {d.get('current_drift_score', 0):.4f}")
print(f" Detectors: {d.get('detectors_active', 0)} active")
print(f" Total: {d.get('detections_total', 0)} detections")
def cmd_search(args):
"""Search for similar happiness vectors."""
print_header("Happiness kNN Search")
# Reference vectors for common moods
refs = {
"happy": [0.8, 0.7, 0.9, 0.8, 0.6, 0.7, 0.9, 0.5],
"neutral": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
"stressed":[0.2, 0.3, 0.2, 0.2, 0.3, 0.3, 0.2, 0.7],
}
query = refs.get(args.mood, refs["happy"])
print(f" Query mood: {args.mood}")
print(f" Vector: [{', '.join(f'{v:.1f}' for v in query)}]")
print(f" k: {args.k}")
print()
result = api(args.seed, "/api/v1/store/search", args.token,
method="POST", data={"vector": query, "k": args.k})
if "error" in result:
print(f" Error: {result['error']}")
return
neighbors = result.get("neighbors", result.get("results", []))
if not neighbors:
print(" No results found.")
return
print(f" {'ID':>10} {'Distance':>10} {'Vector'}")
print(f" {'-'*10} {'-'*10} {'-'*40}")
for n in neighbors:
vid = n.get("id", "?")
dist = n.get("distance", n.get("dist", 0))
vec = n.get("vector", n.get("values", []))
vec_str = "[" + ", ".join(f"{v:.2f}" for v in vec[:4]) + ", ...]" if len(vec) > 4 else str(vec)
print(f" {vid:>10} {dist:>10.4f} {vec_str}")
def cmd_witness(args):
"""Show the witness chain for audit trail."""
print_header("Witness Chain (Audit Trail)")
epoch = api(args.seed, "/api/v1/custody/epoch", args.token)
if "error" not in epoch:
print(f" Current epoch: {epoch.get('epoch', '?')}")
head = epoch.get("witness_head", "?")
print(f" Chain head: {head[:16]}..." if len(head) > 16 else f" Chain head: {head}")
chain = api(args.seed, "/api/v1/cognitive/status", args.token)
if "error" not in chain:
cv = chain.get("chain_valid", {})
print(f" Chain valid: {cv.get('valid', '?')}")
print(f" Chain length: {cv.get('chain_length', '?')}")
print(f" Epoch range: {cv.get('first_epoch', '?')} - {cv.get('last_epoch', '?')}")
def cmd_monitor(args):
"""Live monitor happiness vectors flowing into the Seed."""
print_header("Live Happiness Monitor")
print(f" Polling every {args.interval}s (Ctrl+C to stop)")
print()
prev_epoch = 0
prev_vectors = 0
try:
while True:
s = api(args.seed, "/api/v1/status", args.token)
if "error" in s:
print(f" [{time.strftime('%H:%M:%S')}] Error: {s['error']}")
time.sleep(args.interval)
continue
epoch = s["epoch"]
vectors = s["total_vectors"]
new_v = vectors - prev_vectors if prev_vectors > 0 else 0
new_e = epoch - prev_epoch if prev_epoch > 0 else 0
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
drift = d.get("current_drift_score", 0) if "error" not in d else 0
drifting = d.get("drifting", False) if "error" not in d else False
ts = time.strftime("%H:%M:%S")
drift_str = f" DRIFT!" if drifting else ""
print(f" [{ts}] epoch={epoch} vectors={vectors} (+{new_v}) "
f"drift={drift:.4f} chain={s['witness_chain_length']}{drift_str}")
prev_epoch = epoch
prev_vectors = vectors
time.sleep(args.interval)
except KeyboardInterrupt:
print("\n Stopped.")
def cmd_happiness_report(args):
"""Generate a happiness report from stored vectors."""
print_header("Happiness Report")
s = api(args.seed, "/api/v1/status", args.token)
if "error" in s:
print(f" Error: {s['error']}")
return
print(f" Total vectors: {s['total_vectors']}")
print(f" Store epoch: {s['epoch']}")
print()
# Search for happiest and saddest vectors
happy_ref = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5]
sad_ref = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5]
print(" Happiest moments (closest to ideal happy):")
happy = api(args.seed, "/api/v1/store/search", args.token,
method="POST", data={"vector": happy_ref, "k": 3})
for n in happy.get("neighbors", happy.get("results", [])):
dist = n.get("distance", n.get("dist", 0))
vec = n.get("vector", n.get("values", []))
score = vec[0] if vec else 0
print(f" id={n.get('id','?'):>10} happiness={score:.2f} dist={dist:.4f}")
print()
print(" Most stressed moments (closest to stressed reference):")
sad = api(args.seed, "/api/v1/store/search", args.token,
method="POST", data={"vector": sad_ref, "k": 3})
for n in sad.get("neighbors", sad.get("results", [])):
dist = n.get("distance", n.get("dist", 0))
vec = n.get("vector", n.get("values", []))
score = vec[0] if vec else 0
print(f" id={n.get('id','?'):>10} happiness={score:.2f} dist={dist:.4f}")
# Drift status
print()
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
if "error" not in d:
if d.get("drifting"):
print(f" WARNING: Mood drift detected (score={d['current_drift_score']:.4f})")
print(f" This may indicate a change in guest satisfaction.")
else:
print(f" Mood stable (drift score={d.get('current_drift_score', 0):.4f})")
def main():
parser = argparse.ArgumentParser(
description="Happiness Vector Query Tool for Cognitum Seed",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s status --seed http://169.254.42.1
%(prog)s search --seed http://10.1.10.236 --token TOKEN --mood happy
%(prog)s monitor --seed http://10.1.10.236 --token TOKEN
%(prog)s report --seed http://10.1.10.236 --token TOKEN
%(prog)s witness --seed http://10.1.10.236 --token TOKEN
"""
)
parser.add_argument("--seed", default="http://169.254.42.1",
help="Seed base URL (default: USB link-local)")
parser.add_argument("--token", default=None,
help="Bearer token for WiFi access (not needed for USB)")
sub = parser.add_subparsers(dest="command")
sub.add_parser("status", help="Show Seed and swarm status")
sub.add_parser("witness", help="Show witness chain audit trail")
p_search = sub.add_parser("search", help="kNN search for mood patterns")
p_search.add_argument("--mood", default="happy",
choices=["happy", "neutral", "stressed"])
p_search.add_argument("--k", type=int, default=5)
p_monitor = sub.add_parser("monitor", help="Live monitor incoming vectors")
p_monitor.add_argument("--interval", type=int, default=5)
sub.add_parser("report", help="Generate happiness report")
args = parser.parse_args()
if not args.command:
args.command = "status"
cmds = {
"status": cmd_status,
"search": cmd_search,
"witness": cmd_witness,
"monitor": cmd_monitor,
"report": cmd_happiness_report,
}
cmds[args.command](args)
if __name__ == "__main__":
main()