atlas
Atlas module — MongoDB Atlas connection, collection setup, teach/identify pipeline.
Collections: runs — raw telemetry arrays, one document per motion run signatures — extracted feature vector + embedding, indexed for vector search items — ground-truth item catalogue (label, description, photo_url)
1""" 2Atlas module — MongoDB Atlas connection, collection setup, teach/identify pipeline. 3 4Collections: 5 runs — raw telemetry arrays, one document per motion run 6 signatures — extracted feature vector + embedding, indexed for vector search 7 items — ground-truth item catalogue (label, description, photo_url) 8""" 9 10import argparse 11import os 12import re 13from collections import defaultdict 14from datetime import datetime, timezone 15 16import certifi 17import numpy as np 18from dotenv import load_dotenv 19from pymongo import MongoClient 20from pymongo.errors import CollectionInvalid, OperationFailure 21from pymongo.operations import SearchIndexModel 22 23load_dotenv() 24 25MONGODB_URI = os.getenv("MONGODB_URI") 26DB_NAME = os.getenv("MONGODB_DATABASE", "physical_ai_db") 27 28# Vector search indexes on signatures.feature_vector, keyed by embedding dim. 29# 10-D serves hand/pca; the chronos time-series FM needs its own 2304-D index. 30VECTOR_INDEXES = { 31 10: "cup_dynamics_index", 32 2304: "cup_dynamics_chronos_index", 33} 34 35 36def _index_for_dim(dim: int) -> str: 37 if dim not in VECTOR_INDEXES: 38 raise ValueError( 39 f"no vector index for {dim}-D embeddings (have: {sorted(VECTOR_INDEXES)})") 40 return VECTOR_INDEXES[dim] 41 42_client: MongoClient | None = None 43 44 45def get_db(): 46 """Return the MongoDB database handle, connecting on first call. 47 48 Returns: 49 The feels database object. 50 Raises: 51 RuntimeError: when MONGODB_URI is not set. 52 """ 53 global _client 54 if _client is None: 55 if not MONGODB_URI: 56 raise RuntimeError( 57 "MONGODB_URI not set. Add it to your .env file.\n" 58 "Format: mongodb+srv://<user>:<pass>@<cluster>.mongodb.net/" 59 ) 60 _client = MongoClient(MONGODB_URI, tlsCAFile=certifi.where()) 61 return _client[DB_NAME] 62 63 64# --------------------------------------------------------------------------- 65# One-time setup: create collections + basic indexes 66# --------------------------------------------------------------------------- 67 68def setup_collections(): 69 """ 70 Create the three collections and their scalar indexes. 71 The vector search index on signatures.feature_vector must be created 72 in the Atlas UI (see instructions below). 73 Run this once at the start of the hackathon. 74 """ 75 db = get_db() 76 for name in ("runs", "signatures", "items"): 77 try: 78 db.create_collection(name) 79 print(f" created collection: {name}") 80 except CollectionInvalid: 81 print(f" collection already exists: {name}") 82 83 # Scalar indexes for fast filtering 84 db.signatures.create_index([("label", 1)]) 85 db.signatures.create_index([("session", 1)]) 86 db.signatures.create_index([("label", 1), ("session", 1)]) 87 db.items.create_index([("label", 1)], unique=True) 88 print(" scalar indexes created") 89 90 # Vector Search indexes (requires Atlas M0+ cluster) 91 _create_vector_index(db, "cup_dynamics_index", 10) 92 93 94# --------------------------------------------------------------------------- 95# Vector Search index creation 96# --------------------------------------------------------------------------- 97 98def _create_vector_index(db, name, dims): 99 model = SearchIndexModel( 100 definition={ 101 "fields": [ 102 { 103 "type": "vector", 104 "path": "feature_vector", 105 "numDimensions": dims, 106 "similarity": "cosine", 107 }, 108 {"type": "filter", "path": "label"}, 109 {"type": "filter", "path": "session"}, 110 ] 111 }, 112 name=name, 113 type="vectorSearch", 114 ) 115 try: 116 db.signatures.create_search_index(model) 117 print(f" vector search index '{name}' creation requested — takes ~1 min to build") 118 print(" check status: Atlas UI → your cluster → Atlas Search tab") 119 except OperationFailure as e: 120 if "already exists" in str(e): 121 print(f" vector search index '{name}' already exists — skipping") 122 else: 123 print(f" vector search index error: {e}") 124 125 126def ensure_vector_indexes(which="all"): 127 """Create Atlas vector search indexes. Idempotent — safe to re-run. 128 129 Run once before first use of an embedder whose index doesn't exist yet: 130 python -c "import atlas; atlas.ensure_vector_indexes('chronos')" 131 132 which: "all" | "index_10d" | "chronos" 133 """ 134 db = get_db() 135 targets = {"index_10d": ("cup_dynamics_index", 10), 136 "chronos": ("cup_dynamics_chronos_index", 2304)} 137 if which != "all": 138 targets = {which: targets[which]} 139 for _, (name, dims) in targets.items(): 140 _create_vector_index(db, name, dims) 141 142 143# --------------------------------------------------------------------------- 144# Schema helpers 145# --------------------------------------------------------------------------- 146 147def _run_doc(label: str, motion_type: str, raw_data: list[float], session: str = "thu") -> dict: 148 return { 149 "label": label, 150 "motion_type": motion_type, 151 "raw_data": raw_data, 152 "session": session, 153 "created_at": datetime.now(timezone.utc), 154 } 155 156 157def _signature_doc( 158 label: str, 159 feature_vector: list[float], 160 motion_type: str, 161 properties: dict | None = None, 162 session: str = "thu", 163 run_id=None, 164) -> dict: 165 return { 166 "label": label, 167 "motion_type": motion_type, 168 "feature_vector": feature_vector, 169 "properties": properties or {}, 170 "session": session, 171 "run_id": run_id, 172 "created_at": datetime.now(timezone.utc), 173 } 174 175 176# --------------------------------------------------------------------------- 177# teach() — store a labeled run + its signature 178# --------------------------------------------------------------------------- 179 180def teach( 181 label: str, 182 feature_vector: list[float], 183 raw_data: list[float] | None = None, 184 motion_type: str = "3in1_macro", 185 properties: dict | None = None, 186 session: str = "thu", 187) -> dict: 188 """ 189 Insert a labeled run into Atlas. 190 191 Args: 192 label: e.g. "dish_soap_half_full" 193 feature_vector: normalized embedding list[float] (10-D hand/pca or 2304-D chronos) 194 raw_data: optional raw joint torque time-series 195 motion_type: "3in1_macro" | "sine_shake" | "tilt" | "fig8" 196 properties: optional dict {"estimated_mass_g": 185, "fill_ratio": 0.5} 197 session: "thu" or "fri" — used to filter vector search by day 198 199 Returns: 200 dict with inserted run_id and sig_id 201 """ 202 db = get_db() 203 204 run_id = None 205 if raw_data is not None: 206 result = db.runs.insert_one(_run_doc(label, motion_type, raw_data, session)) 207 run_id = result.inserted_id 208 209 sig_result = db.signatures.insert_one( 210 _signature_doc(label, feature_vector, motion_type, properties, session, run_id) 211 ) 212 213 print(f"[teach] stored '{label}' sig_id={sig_result.inserted_id}") 214 return {"run_id": str(run_id), "sig_id": str(sig_result.inserted_id)} 215 216 217# --------------------------------------------------------------------------- 218# identify() — k-NN vector search, returns label + confidence 219# --------------------------------------------------------------------------- 220 221# --------------------------------------------------------------------------- 222# Material taxonomy: what matters is WHAT is inside, not how full the cup is. 223# Taught labels carry capacity suffixes (water_025pct, solid_150g, 224# dish_soap_half); material_of() strips them to the coarse material class. 225# New labels should follow the same <material>_<capacity> convention, or be 226# added to MATERIAL_STEMS. 227# --------------------------------------------------------------------------- 228 229MATERIAL_STEMS = { 230 "water": "water", 231 "soap": "soap", 232 "dish_soap": "soap", 233 "candy": "candy", 234 "solid": "solid", 235 "screws": "screws", 236 "screw": "screws", 237 "empty": "empty", 238 "empty_cup": "empty", 239} 240 241_CAPACITY_SUFFIX = re.compile(r"_(\d+(pct|g|ml|oz)|half|full|mixed|empty)$") 242 243 244def material_of(label: str) -> str: 245 """Coarse material class for a taught label. 246 247 water_025pct / water_150g / water_half -> water; solid_020g -> solid; 248 dish_soap_half -> soap; candy_mixed -> candy; empty_cup -> empty. 249 Unknown labels map to themselves (their own material class). 250 251 Args: 252 label: taught signature label, e.g. "water_075pct". 253 Returns: 254 Material stem, e.g. "water". 255 """ 256 stem = _CAPACITY_SUFFIX.sub("", label.strip().lower()) 257 return MATERIAL_STEMS.get(stem, stem) 258 259 260# Coarser still: the demo headline. Liquids slosh (variance + phase lag), 261# solids stay rigidly coupled or rattle, empty is empty. Fine material type 262# (water vs soap) is a harder claim than the 3.5 Hz signal reliably supports; 263# the category is the robust one. 264CATEGORY_OF_MATERIAL = { 265 "water": "liquid", 266 "soap": "liquid", 267 "solid": "solid", 268 "screws": "solid", 269 "candy": "solid", 270 "empty": "empty", 271} 272 273 274def category_of(label: str) -> str: 275 """Coarse category for a taught label: liquid / solid / empty. 276 277 Unknown materials map to themselves (their own category). 278 279 Args: 280 label: taught signature label, e.g. "screws_100g". 281 Returns: 282 "liquid", "solid", "empty", or the material itself when unknown. 283 """ 284 mat = material_of(label) 285 return CATEGORY_OF_MATERIAL.get(mat, mat) 286 287 288def identify( 289 feature_vector: list[float], 290 top_k: int = 5, 291 session: str | None = None, 292 confidence_threshold: float = 0.82, 293) -> dict: 294 """ 295 Query Atlas Vector Search to identify what's in the cup. 296 297 Args: 298 feature_vector: normalized query embedding — 10-D (hand/pca) or 299 2304-D (chronos); the vector index is picked by dim 300 top_k: number of neighbours to retrieve 301 session: if set, filter to this session tag ("thu"/"fri") 302 confidence_threshold: below this score, flag as low-confidence 303 304 Returns: 305 { 306 "label": predicted label string (finest taught granularity), 307 "confidence": float 0–1, 308 "matches": list of top-k {label, score} dicts, 309 "low_confidence": bool, 310 "material": predicted material class (capacity suffixes stripped), 311 "material_confidence": float 0–1, 312 "material_matches": list of {material, score} dicts, 313 "material_low_confidence": bool, 314 "category": predicted category (liquid / solid / empty), 315 "category_confidence": float 0–1, 316 "category_matches": list of {category, score} dicts, 317 "category_low_confidence": bool 318 } 319 """ 320 db = get_db() 321 322 # Build the $vectorSearch stage (index picked by embedding dimension) 323 vector_search_stage = { 324 "$vectorSearch": { 325 "index": _index_for_dim(len(feature_vector)), 326 "path": "feature_vector", 327 "queryVector": feature_vector, 328 "numCandidates": top_k * 4, 329 "limit": top_k, 330 **({"filter": {"session": session}} if session else {}), 331 } 332 } 333 334 project_stage = { 335 "$project": { 336 "label": 1, 337 "session": 1, 338 "score": {"$meta": "vectorSearchScore"}, 339 "_id": 0, 340 } 341 } 342 343 results = list(db.signatures.aggregate([vector_search_stage, project_stage])) 344 345 if not results: 346 # Empty library: same dict shape as a real result so the dashboard 347 # renders "teach items first" instead of 500ing on a KeyError. 348 return {"label": None, "confidence": 0.0, "matches": [], 349 "margin": 1.0, "ambiguous": False, "low_confidence": True, 350 "material": None, "material_confidence": 0.0, "material_matches": [], 351 "material_margin": 1.0, "material_ambiguous": False, 352 "material_low_confidence": True, 353 "category": None, "category_confidence": 0.0, "category_matches": [], 354 "category_margin": 1.0, "category_ambiguous": False, 355 "category_low_confidence": True} 356 357 # Group by label: mean similarity of each label's hits 358 label_scores: dict[str, list[float]] = defaultdict(list) 359 for r in results: 360 label_scores[r["label"]].append(r["score"]) 361 362 ranked = sorted( 363 {lbl: sum(scores) / len(scores) for lbl, scores in label_scores.items()}.items(), 364 key=lambda x: x[1], 365 reverse=True, 366 ) 367 368 best_label, best_confidence = ranked[0] 369 margin = best_confidence - ranked[1][1] if len(ranked) > 1 else 1.0 370 # A tiny lead across labels means the space can't separate this query 371 # (e.g. stale feature scales collapsing every embedding to ~0.97 cosine). 372 # Report ambiguity instead of a confident-looking number. 373 ambiguous = margin < 0.02 374 matches = [{"label": lbl, "score": round(score, 4)} for lbl, score in ranked] 375 376 # Material-level aggregation: capacity variants of one material vote 377 # together. water_025pct vs water_075pct is a fill question; water vs 378 # soap is the material question the demo actually asks. 379 mat_scores: dict[str, list[float]] = defaultdict(list) 380 for r in results: 381 mat_scores[material_of(r["label"])].append(r["score"]) 382 mat_ranked = sorted( 383 {m: sum(s) / len(s) for m, s in mat_scores.items()}.items(), 384 key=lambda x: x[1], reverse=True, 385 ) 386 best_mat, best_mat_conf = mat_ranked[0] 387 mat_margin = best_mat_conf - mat_ranked[1][1] if len(mat_ranked) > 1 else 1.0 388 mat_ambiguous = mat_margin < 0.02 389 mat_matches = [{"material": m, "score": round(s, 4)} for m, s in mat_ranked] 390 391 print(f"[identify] → '{best_label}' confidence={best_confidence:.3f} margin={margin:.3f}" 392 + (" AMBIGUOUS" if ambiguous else "")) 393 print(f" material '{best_mat}' confidence={best_mat_conf:.3f} margin={mat_margin:.3f}" 394 + (" AMBIGUOUS" if mat_ambiguous else "")) 395 396 # Category-level aggregation: liquid / solid / empty. The coarsest claim 397 # and the most robust — the level the demo headlines. 398 cat_scores: dict[str, list[float]] = defaultdict(list) 399 for r in results: 400 cat_scores[category_of(r["label"])].append(r["score"]) 401 cat_ranked = sorted( 402 {c: sum(s) / len(s) for c, s in cat_scores.items()}.items(), 403 key=lambda x: x[1], reverse=True, 404 ) 405 best_cat, best_cat_conf = cat_ranked[0] 406 cat_margin = best_cat_conf - cat_ranked[1][1] if len(cat_ranked) > 1 else 1.0 407 cat_ambiguous = cat_margin < 0.02 408 cat_matches = [{"category": c, "score": round(s, 4)} for c, s in cat_ranked] 409 print(f" category '{best_cat}' confidence={best_cat_conf:.3f} margin={cat_margin:.3f}" 410 + (" AMBIGUOUS" if cat_ambiguous else "")) 411 for m in matches: 412 print(f" {m['label']:30s} {m['score']:.4f}") 413 414 return { 415 "label": best_label, 416 "confidence": best_confidence, 417 "matches": matches, 418 "margin": round(margin, 4), 419 "ambiguous": ambiguous, 420 "low_confidence": (best_confidence < confidence_threshold) or ambiguous, 421 "material": best_mat, 422 "material_confidence": best_mat_conf, 423 "material_matches": mat_matches, 424 "material_margin": round(mat_margin, 4), 425 "material_ambiguous": mat_ambiguous, 426 "material_low_confidence": (best_mat_conf < confidence_threshold) or mat_ambiguous, 427 "category": best_cat, 428 "category_confidence": best_cat_conf, 429 "category_matches": cat_matches, 430 "category_margin": round(cat_margin, 4), 431 "category_ambiguous": cat_ambiguous, 432 "category_low_confidence": (best_cat_conf < confidence_threshold) or cat_ambiguous, 433 } 434 435 436# --------------------------------------------------------------------------- 437# Supervised decision head (LDA) 438# --------------------------------------------------------------------------- 439 440# Measured held-out accuracy of the LDA head (manifest 84/21 split, Jan-2026 441# bake-off). Displayed as the trust signal because LDA's own probabilities 442# are uncalibrated (correct vs wrong predictions are indistinguishable at 443# ~0.57 vs ~0.55 max-proba). Honest beats precise. 444EMPIRICAL_ACCURACY = { 445 "label": 0.43, 446 "material": 0.43, 447 "category": 0.52, 448 "binary": 0.86, 449} 450 451 452def _lda_library(db): 453 """Fetch (label, z) for every signature with a stored raw feature vector.""" 454 docs = list(db.signatures.find({"properties.z": {"$exists": True}}, 455 {"label": 1, "properties.z": 1, "_id": 0})) 456 labels, Z = [], [] 457 for d in docs: 458 z = (d.get("properties") or {}).get("z") 459 if d.get("label") and isinstance(z, (list, tuple)) and len(z) == 10: 460 try: 461 labels.append(str(d["label"])) 462 Z.append([float(v) for v in z]) 463 except (TypeError, ValueError): 464 continue 465 return labels, np.array(Z, dtype=float) if Z else np.zeros((0, 10)) 466 467 468def predict_lda(query_z, confidence_threshold=0.82): 469 """Supervised decision head: shrinkage-LDA on standardized z. 470 471 Fetches the whole labeled library, fits StandardScaler + LDA in 472 milliseconds (no training step, no deployment — refit on every predict, 473 so a teach is effective immediately), and returns the same dict shape 474 as identify() so the dashboard works unchanged. 475 476 Measured accuracy (held-out): category 52%, material 43%, empty/nonempty 477 86% — roughly 2x the vector-search retrieval rule on the same features. 478 Raises ValueError when the library can't support it; callers fall back 479 to identify(). 480 481 Args: 482 query_z: 10-D raw feature vector of the query run. 483 confidence_threshold: below this score the result is flagged low-confidence. 484 Returns: 485 Dict with label, material, category, matches, margins, and 486 decider="lda" — the same shape as identify(). 487 """ 488 from sklearn.discriminant_analysis import LinearDiscriminantAnalysis 489 from sklearn.preprocessing import StandardScaler 490 491 db = get_db() 492 labels, Z = _lda_library(db) 493 n_classes = len(set(labels)) 494 if len(Z) < 10 or n_classes < 2: 495 raise ValueError( 496 f"LDA needs >=10 signatures across >=2 classes, have {len(Z)}/{n_classes}") 497 qz = np.asarray(query_z, dtype=float).reshape(1, -1) 498 if qz.shape[1] != Z.shape[1]: 499 raise ValueError(f"query z dim {qz.shape[1]} != library {Z.shape[1]}") 500 501 sc = StandardScaler().fit(Z) 502 clf = LinearDiscriminantAnalysis(solver="lsqr", shrinkage="auto") 503 clf.fit(sc.transform(Z), labels) 504 proba = clf.predict_proba(sc.transform(qz))[0] 505 classes = [str(c) for c in clf.classes_] 506 order = np.argsort(proba)[::-1] 507 508 def aggregate(fn): 509 """Sum class probabilities into groups (material / category) and rank.""" 510 groups = {} 511 for i in order: 512 g = fn(classes[i]) 513 groups[g] = groups.get(g, 0.0) + float(proba[i]) 514 return sorted(groups.items(), key=lambda kv: kv[1], reverse=True) 515 516 matches = [{"label": classes[i], "score": round(float(proba[i]), 4)} 517 for i in order[:5]] 518 best_label = classes[order[0]] 519 mat_ranked = aggregate(material_of) 520 cat_ranked = aggregate(category_of) 521 best_mat, best_mat_p = mat_ranked[0] 522 best_cat, best_cat_p = cat_ranked[0] 523 # close call = top two within 0.15 probability (weak signal, but honest) 524 close = (float(proba[order[0]]) - float(proba[order[1]])) < 0.15 if len(order) > 1 else False 525 mat_close = (mat_ranked[0][1] - mat_ranked[1][1]) < 0.15 if len(mat_ranked) > 1 else False 526 cat_close = (cat_ranked[0][1] - cat_ranked[1][1]) < 0.15 if len(cat_ranked) > 1 else False 527 528 print(f"[predict_lda] → '{best_label}' material '{best_mat}' category '{best_cat}'" 529 + (" CLOSE-CALL" if close else "")) 530 531 return { 532 "decider": "lda", 533 "label": best_label, 534 "confidence": EMPIRICAL_ACCURACY["material"], 535 "matches": matches, 536 "margin": round(float(proba[order[0]] - proba[order[1]]) if len(order) > 1 else 1.0, 4), 537 "ambiguous": close, 538 "low_confidence": close, 539 "material": best_mat, 540 "material_confidence": EMPIRICAL_ACCURACY["material"], 541 "material_matches": [{"material": m, "score": round(float(p), 4)} for m, p in mat_ranked], 542 "material_margin": round(float(mat_ranked[0][1] - mat_ranked[1][1]) if len(mat_ranked) > 1 else 1.0, 4), 543 "material_ambiguous": mat_close, 544 "material_low_confidence": mat_close, 545 "category": best_cat, 546 "category_confidence": EMPIRICAL_ACCURACY["category"], 547 "category_matches": [{"category": c, "score": round(float(p), 4)} for c, p in cat_ranked], 548 "category_margin": round(float(cat_ranked[0][1] - cat_ranked[1][1]) if len(cat_ranked) > 1 else 1.0, 4), 549 "category_ambiguous": cat_close, 550 "category_low_confidence": cat_close, 551 "n_signatures": len(Z), 552 } 553 554 555# --------------------------------------------------------------------------- 556# Seed the item catalogue 557# --------------------------------------------------------------------------- 558 559BASELINE_ITEMS = [ 560 { 561 "label": "empty_cup", 562 "description": "Empty lidded coffee cup — baseline tare reference", 563 "category": "empty", 564 "expected_signature": "Low mass, near-zero shake resistance, no rattle", 565 }, 566 { 567 "label": "water_half", 568 "description": "Half-full cup of water", 569 "category": "newtonian_liquid", 570 "expected_signature": "Medium mass, dynamic phase lag, low high-frequency rattle", 571 }, 572 { 573 "label": "dish_soap_half", 574 "description": "Half-full cup of dish soap", 575 "category": "viscous_liquid", 576 "expected_signature": "Medium mass, heavy viscous damping, delayed peak torque", 577 }, 578 { 579 "label": "candy_loose", 580 "description": "Halloween fun-size candy (loose pieces)", 581 "category": "loose_solid", 582 "expected_signature": "Sharp high-frequency impact chatter (5–25 Hz rattle peaks)", 583 }, 584 { 585 "label": "solid_weight", 586 "description": "Small metal tool or solid roll of tape", 587 "category": "rigid_solid", 588 "expected_signature": "High mass, rigid coupling, zero rattle, zero phase lag", 589 }, 590] 591 592 593def seed_items(overwrite: bool = False): 594 """Insert baseline items into the items catalogue.""" 595 db = get_db() 596 for item in BASELINE_ITEMS: 597 if overwrite: 598 db.items.replace_one({"label": item["label"]}, item, upsert=True) 599 else: 600 db.items.update_one({"label": item["label"]}, {"$setOnInsert": item}, upsert=True) 601 print(f" seeded item: {item['label']}") 602 603 604# --------------------------------------------------------------------------- 605# Quick smoke test — run directly: python atlas.py 606# --------------------------------------------------------------------------- 607 608if __name__ == "__main__": 609 parser = argparse.ArgumentParser( 610 description="Set up Atlas collections/vector index for arii/feels.", 611 epilog="""Run once per fresh database. Needs MONGODB_URI in .env, and the Atlas 612IP allowlist must include this machine. Plain `python atlas.py` only creates 613collections + the vector index and seeds the item catalogue — it does NOT 614touch the signature library. `--smoke` additionally inserts one RANDOM 615vector; only use it on an empty database.""", 616 formatter_class=argparse.RawDescriptionHelpFormatter) 617 parser.add_argument("--smoke", action="store_true", 618 help="Also run a smoke teach() with a RANDOM vector + identify(). " 619 "DO NOT use after real items are taught — it inserts a junk " 620 "'water_half' row that will pollute $vectorSearch results.") 621 args = parser.parse_args() 622 623 print("=== Atlas setup ===\n") 624 print(f"Database: {DB_NAME}") 625 626 db = get_db() 627 print(f"Connected. Collections: {db.list_collection_names()}\n") 628 629 print("--- Setting up collections ---") 630 setup_collections() 631 632 print("\n--- Seeding item catalogue ---") 633 seed_items() 634 635 if args.smoke: 636 print("\n--- Inserting a fake teach() run (smoke test, random vector) ---") 637 fake_vector = [0.1, 0.9, -0.3, 0.5, 0.2, -0.1, 0.4, 0.0, 0.3, 0.6] 638 teach( 639 label="water_half", 640 feature_vector=fake_vector, 641 motion_type="3in1_macro", 642 properties={"estimated_mass_g": 150, "fill_ratio": 0.5}, 643 session="thu", 644 ) 645 646 print("\n--- Running identify() ---") 647 result = identify(fake_vector, top_k=5, session="thu") 648 print(f"\n Prediction : {result['label']}") 649 print(f" Confidence : {result['confidence']:.3f}") 650 print(f" Low conf? : {result['low_confidence']}") 651 print("\nDone.")
46def get_db(): 47 """Return the MongoDB database handle, connecting on first call. 48 49 Returns: 50 The feels database object. 51 Raises: 52 RuntimeError: when MONGODB_URI is not set. 53 """ 54 global _client 55 if _client is None: 56 if not MONGODB_URI: 57 raise RuntimeError( 58 "MONGODB_URI not set. Add it to your .env file.\n" 59 "Format: mongodb+srv://<user>:<pass>@<cluster>.mongodb.net/" 60 ) 61 _client = MongoClient(MONGODB_URI, tlsCAFile=certifi.where()) 62 return _client[DB_NAME]
Return the MongoDB database handle, connecting on first call.
Returns: The feels database object. Raises: RuntimeError: when MONGODB_URI is not set.
69def setup_collections(): 70 """ 71 Create the three collections and their scalar indexes. 72 The vector search index on signatures.feature_vector must be created 73 in the Atlas UI (see instructions below). 74 Run this once at the start of the hackathon. 75 """ 76 db = get_db() 77 for name in ("runs", "signatures", "items"): 78 try: 79 db.create_collection(name) 80 print(f" created collection: {name}") 81 except CollectionInvalid: 82 print(f" collection already exists: {name}") 83 84 # Scalar indexes for fast filtering 85 db.signatures.create_index([("label", 1)]) 86 db.signatures.create_index([("session", 1)]) 87 db.signatures.create_index([("label", 1), ("session", 1)]) 88 db.items.create_index([("label", 1)], unique=True) 89 print(" scalar indexes created") 90 91 # Vector Search indexes (requires Atlas M0+ cluster) 92 _create_vector_index(db, "cup_dynamics_index", 10)
Create the three collections and their scalar indexes. The vector search index on signatures.feature_vector must be created in the Atlas UI (see instructions below). Run this once at the start of the hackathon.
127def ensure_vector_indexes(which="all"): 128 """Create Atlas vector search indexes. Idempotent — safe to re-run. 129 130 Run once before first use of an embedder whose index doesn't exist yet: 131 python -c "import atlas; atlas.ensure_vector_indexes('chronos')" 132 133 which: "all" | "index_10d" | "chronos" 134 """ 135 db = get_db() 136 targets = {"index_10d": ("cup_dynamics_index", 10), 137 "chronos": ("cup_dynamics_chronos_index", 2304)} 138 if which != "all": 139 targets = {which: targets[which]} 140 for _, (name, dims) in targets.items(): 141 _create_vector_index(db, name, dims)
Create Atlas vector search indexes. Idempotent — safe to re-run.
Run once before first use of an embedder whose index doesn't exist yet: python -c "import atlas; atlas.ensure_vector_indexes('chronos')"
which: "all" | "index_10d" | "chronos"
181def teach( 182 label: str, 183 feature_vector: list[float], 184 raw_data: list[float] | None = None, 185 motion_type: str = "3in1_macro", 186 properties: dict | None = None, 187 session: str = "thu", 188) -> dict: 189 """ 190 Insert a labeled run into Atlas. 191 192 Args: 193 label: e.g. "dish_soap_half_full" 194 feature_vector: normalized embedding list[float] (10-D hand/pca or 2304-D chronos) 195 raw_data: optional raw joint torque time-series 196 motion_type: "3in1_macro" | "sine_shake" | "tilt" | "fig8" 197 properties: optional dict {"estimated_mass_g": 185, "fill_ratio": 0.5} 198 session: "thu" or "fri" — used to filter vector search by day 199 200 Returns: 201 dict with inserted run_id and sig_id 202 """ 203 db = get_db() 204 205 run_id = None 206 if raw_data is not None: 207 result = db.runs.insert_one(_run_doc(label, motion_type, raw_data, session)) 208 run_id = result.inserted_id 209 210 sig_result = db.signatures.insert_one( 211 _signature_doc(label, feature_vector, motion_type, properties, session, run_id) 212 ) 213 214 print(f"[teach] stored '{label}' sig_id={sig_result.inserted_id}") 215 return {"run_id": str(run_id), "sig_id": str(sig_result.inserted_id)}
Insert a labeled run into Atlas.
Args: label: e.g. "dish_soap_half_full" feature_vector: normalized embedding list[float] (10-D hand/pca or 2304-D chronos) raw_data: optional raw joint torque time-series motion_type: "3in1_macro" | "sine_shake" | "tilt" | "fig8" properties: optional dict {"estimated_mass_g": 185, "fill_ratio": 0.5} session: "thu" or "fri" — used to filter vector search by day
Returns: dict with inserted run_id and sig_id
245def material_of(label: str) -> str: 246 """Coarse material class for a taught label. 247 248 water_025pct / water_150g / water_half -> water; solid_020g -> solid; 249 dish_soap_half -> soap; candy_mixed -> candy; empty_cup -> empty. 250 Unknown labels map to themselves (their own material class). 251 252 Args: 253 label: taught signature label, e.g. "water_075pct". 254 Returns: 255 Material stem, e.g. "water". 256 """ 257 stem = _CAPACITY_SUFFIX.sub("", label.strip().lower()) 258 return MATERIAL_STEMS.get(stem, stem)
Coarse material class for a taught label.
water_025pct / water_150g / water_half -> water; solid_020g -> solid; dish_soap_half -> soap; candy_mixed -> candy; empty_cup -> empty. Unknown labels map to themselves (their own material class).
Args: label: taught signature label, e.g. "water_075pct". Returns: Material stem, e.g. "water".
275def category_of(label: str) -> str: 276 """Coarse category for a taught label: liquid / solid / empty. 277 278 Unknown materials map to themselves (their own category). 279 280 Args: 281 label: taught signature label, e.g. "screws_100g". 282 Returns: 283 "liquid", "solid", "empty", or the material itself when unknown. 284 """ 285 mat = material_of(label) 286 return CATEGORY_OF_MATERIAL.get(mat, mat)
Coarse category for a taught label: liquid / solid / empty.
Unknown materials map to themselves (their own category).
Args: label: taught signature label, e.g. "screws_100g". Returns: "liquid", "solid", "empty", or the material itself when unknown.
289def identify( 290 feature_vector: list[float], 291 top_k: int = 5, 292 session: str | None = None, 293 confidence_threshold: float = 0.82, 294) -> dict: 295 """ 296 Query Atlas Vector Search to identify what's in the cup. 297 298 Args: 299 feature_vector: normalized query embedding — 10-D (hand/pca) or 300 2304-D (chronos); the vector index is picked by dim 301 top_k: number of neighbours to retrieve 302 session: if set, filter to this session tag ("thu"/"fri") 303 confidence_threshold: below this score, flag as low-confidence 304 305 Returns: 306 { 307 "label": predicted label string (finest taught granularity), 308 "confidence": float 0–1, 309 "matches": list of top-k {label, score} dicts, 310 "low_confidence": bool, 311 "material": predicted material class (capacity suffixes stripped), 312 "material_confidence": float 0–1, 313 "material_matches": list of {material, score} dicts, 314 "material_low_confidence": bool, 315 "category": predicted category (liquid / solid / empty), 316 "category_confidence": float 0–1, 317 "category_matches": list of {category, score} dicts, 318 "category_low_confidence": bool 319 } 320 """ 321 db = get_db() 322 323 # Build the $vectorSearch stage (index picked by embedding dimension) 324 vector_search_stage = { 325 "$vectorSearch": { 326 "index": _index_for_dim(len(feature_vector)), 327 "path": "feature_vector", 328 "queryVector": feature_vector, 329 "numCandidates": top_k * 4, 330 "limit": top_k, 331 **({"filter": {"session": session}} if session else {}), 332 } 333 } 334 335 project_stage = { 336 "$project": { 337 "label": 1, 338 "session": 1, 339 "score": {"$meta": "vectorSearchScore"}, 340 "_id": 0, 341 } 342 } 343 344 results = list(db.signatures.aggregate([vector_search_stage, project_stage])) 345 346 if not results: 347 # Empty library: same dict shape as a real result so the dashboard 348 # renders "teach items first" instead of 500ing on a KeyError. 349 return {"label": None, "confidence": 0.0, "matches": [], 350 "margin": 1.0, "ambiguous": False, "low_confidence": True, 351 "material": None, "material_confidence": 0.0, "material_matches": [], 352 "material_margin": 1.0, "material_ambiguous": False, 353 "material_low_confidence": True, 354 "category": None, "category_confidence": 0.0, "category_matches": [], 355 "category_margin": 1.0, "category_ambiguous": False, 356 "category_low_confidence": True} 357 358 # Group by label: mean similarity of each label's hits 359 label_scores: dict[str, list[float]] = defaultdict(list) 360 for r in results: 361 label_scores[r["label"]].append(r["score"]) 362 363 ranked = sorted( 364 {lbl: sum(scores) / len(scores) for lbl, scores in label_scores.items()}.items(), 365 key=lambda x: x[1], 366 reverse=True, 367 ) 368 369 best_label, best_confidence = ranked[0] 370 margin = best_confidence - ranked[1][1] if len(ranked) > 1 else 1.0 371 # A tiny lead across labels means the space can't separate this query 372 # (e.g. stale feature scales collapsing every embedding to ~0.97 cosine). 373 # Report ambiguity instead of a confident-looking number. 374 ambiguous = margin < 0.02 375 matches = [{"label": lbl, "score": round(score, 4)} for lbl, score in ranked] 376 377 # Material-level aggregation: capacity variants of one material vote 378 # together. water_025pct vs water_075pct is a fill question; water vs 379 # soap is the material question the demo actually asks. 380 mat_scores: dict[str, list[float]] = defaultdict(list) 381 for r in results: 382 mat_scores[material_of(r["label"])].append(r["score"]) 383 mat_ranked = sorted( 384 {m: sum(s) / len(s) for m, s in mat_scores.items()}.items(), 385 key=lambda x: x[1], reverse=True, 386 ) 387 best_mat, best_mat_conf = mat_ranked[0] 388 mat_margin = best_mat_conf - mat_ranked[1][1] if len(mat_ranked) > 1 else 1.0 389 mat_ambiguous = mat_margin < 0.02 390 mat_matches = [{"material": m, "score": round(s, 4)} for m, s in mat_ranked] 391 392 print(f"[identify] → '{best_label}' confidence={best_confidence:.3f} margin={margin:.3f}" 393 + (" AMBIGUOUS" if ambiguous else "")) 394 print(f" material '{best_mat}' confidence={best_mat_conf:.3f} margin={mat_margin:.3f}" 395 + (" AMBIGUOUS" if mat_ambiguous else "")) 396 397 # Category-level aggregation: liquid / solid / empty. The coarsest claim 398 # and the most robust — the level the demo headlines. 399 cat_scores: dict[str, list[float]] = defaultdict(list) 400 for r in results: 401 cat_scores[category_of(r["label"])].append(r["score"]) 402 cat_ranked = sorted( 403 {c: sum(s) / len(s) for c, s in cat_scores.items()}.items(), 404 key=lambda x: x[1], reverse=True, 405 ) 406 best_cat, best_cat_conf = cat_ranked[0] 407 cat_margin = best_cat_conf - cat_ranked[1][1] if len(cat_ranked) > 1 else 1.0 408 cat_ambiguous = cat_margin < 0.02 409 cat_matches = [{"category": c, "score": round(s, 4)} for c, s in cat_ranked] 410 print(f" category '{best_cat}' confidence={best_cat_conf:.3f} margin={cat_margin:.3f}" 411 + (" AMBIGUOUS" if cat_ambiguous else "")) 412 for m in matches: 413 print(f" {m['label']:30s} {m['score']:.4f}") 414 415 return { 416 "label": best_label, 417 "confidence": best_confidence, 418 "matches": matches, 419 "margin": round(margin, 4), 420 "ambiguous": ambiguous, 421 "low_confidence": (best_confidence < confidence_threshold) or ambiguous, 422 "material": best_mat, 423 "material_confidence": best_mat_conf, 424 "material_matches": mat_matches, 425 "material_margin": round(mat_margin, 4), 426 "material_ambiguous": mat_ambiguous, 427 "material_low_confidence": (best_mat_conf < confidence_threshold) or mat_ambiguous, 428 "category": best_cat, 429 "category_confidence": best_cat_conf, 430 "category_matches": cat_matches, 431 "category_margin": round(cat_margin, 4), 432 "category_ambiguous": cat_ambiguous, 433 "category_low_confidence": (best_cat_conf < confidence_threshold) or cat_ambiguous, 434 }
Query Atlas Vector Search to identify what's in the cup.
Args: feature_vector: normalized query embedding — 10-D (hand/pca) or 2304-D (chronos); the vector index is picked by dim top_k: number of neighbours to retrieve session: if set, filter to this session tag ("thu"/"fri") confidence_threshold: below this score, flag as low-confidence
Returns: { "label": predicted label string (finest taught granularity), "confidence": float 0–1, "matches": list of top-k {label, score} dicts, "low_confidence": bool, "material": predicted material class (capacity suffixes stripped), "material_confidence": float 0–1, "material_matches": list of {material, score} dicts, "material_low_confidence": bool, "category": predicted category (liquid / solid / empty), "category_confidence": float 0–1, "category_matches": list of {category, score} dicts, "category_low_confidence": bool }
469def predict_lda(query_z, confidence_threshold=0.82): 470 """Supervised decision head: shrinkage-LDA on standardized z. 471 472 Fetches the whole labeled library, fits StandardScaler + LDA in 473 milliseconds (no training step, no deployment — refit on every predict, 474 so a teach is effective immediately), and returns the same dict shape 475 as identify() so the dashboard works unchanged. 476 477 Measured accuracy (held-out): category 52%, material 43%, empty/nonempty 478 86% — roughly 2x the vector-search retrieval rule on the same features. 479 Raises ValueError when the library can't support it; callers fall back 480 to identify(). 481 482 Args: 483 query_z: 10-D raw feature vector of the query run. 484 confidence_threshold: below this score the result is flagged low-confidence. 485 Returns: 486 Dict with label, material, category, matches, margins, and 487 decider="lda" — the same shape as identify(). 488 """ 489 from sklearn.discriminant_analysis import LinearDiscriminantAnalysis 490 from sklearn.preprocessing import StandardScaler 491 492 db = get_db() 493 labels, Z = _lda_library(db) 494 n_classes = len(set(labels)) 495 if len(Z) < 10 or n_classes < 2: 496 raise ValueError( 497 f"LDA needs >=10 signatures across >=2 classes, have {len(Z)}/{n_classes}") 498 qz = np.asarray(query_z, dtype=float).reshape(1, -1) 499 if qz.shape[1] != Z.shape[1]: 500 raise ValueError(f"query z dim {qz.shape[1]} != library {Z.shape[1]}") 501 502 sc = StandardScaler().fit(Z) 503 clf = LinearDiscriminantAnalysis(solver="lsqr", shrinkage="auto") 504 clf.fit(sc.transform(Z), labels) 505 proba = clf.predict_proba(sc.transform(qz))[0] 506 classes = [str(c) for c in clf.classes_] 507 order = np.argsort(proba)[::-1] 508 509 def aggregate(fn): 510 """Sum class probabilities into groups (material / category) and rank.""" 511 groups = {} 512 for i in order: 513 g = fn(classes[i]) 514 groups[g] = groups.get(g, 0.0) + float(proba[i]) 515 return sorted(groups.items(), key=lambda kv: kv[1], reverse=True) 516 517 matches = [{"label": classes[i], "score": round(float(proba[i]), 4)} 518 for i in order[:5]] 519 best_label = classes[order[0]] 520 mat_ranked = aggregate(material_of) 521 cat_ranked = aggregate(category_of) 522 best_mat, best_mat_p = mat_ranked[0] 523 best_cat, best_cat_p = cat_ranked[0] 524 # close call = top two within 0.15 probability (weak signal, but honest) 525 close = (float(proba[order[0]]) - float(proba[order[1]])) < 0.15 if len(order) > 1 else False 526 mat_close = (mat_ranked[0][1] - mat_ranked[1][1]) < 0.15 if len(mat_ranked) > 1 else False 527 cat_close = (cat_ranked[0][1] - cat_ranked[1][1]) < 0.15 if len(cat_ranked) > 1 else False 528 529 print(f"[predict_lda] → '{best_label}' material '{best_mat}' category '{best_cat}'" 530 + (" CLOSE-CALL" if close else "")) 531 532 return { 533 "decider": "lda", 534 "label": best_label, 535 "confidence": EMPIRICAL_ACCURACY["material"], 536 "matches": matches, 537 "margin": round(float(proba[order[0]] - proba[order[1]]) if len(order) > 1 else 1.0, 4), 538 "ambiguous": close, 539 "low_confidence": close, 540 "material": best_mat, 541 "material_confidence": EMPIRICAL_ACCURACY["material"], 542 "material_matches": [{"material": m, "score": round(float(p), 4)} for m, p in mat_ranked], 543 "material_margin": round(float(mat_ranked[0][1] - mat_ranked[1][1]) if len(mat_ranked) > 1 else 1.0, 4), 544 "material_ambiguous": mat_close, 545 "material_low_confidence": mat_close, 546 "category": best_cat, 547 "category_confidence": EMPIRICAL_ACCURACY["category"], 548 "category_matches": [{"category": c, "score": round(float(p), 4)} for c, p in cat_ranked], 549 "category_margin": round(float(cat_ranked[0][1] - cat_ranked[1][1]) if len(cat_ranked) > 1 else 1.0, 4), 550 "category_ambiguous": cat_close, 551 "category_low_confidence": cat_close, 552 "n_signatures": len(Z), 553 }
Supervised decision head: shrinkage-LDA on standardized z.
Fetches the whole labeled library, fits StandardScaler + LDA in milliseconds (no training step, no deployment — refit on every predict, so a teach is effective immediately), and returns the same dict shape as identify() so the dashboard works unchanged.
Measured accuracy (held-out): category 52%, material 43%, empty/nonempty 86% — roughly 2x the vector-search retrieval rule on the same features. Raises ValueError when the library can't support it; callers fall back to identify().
Args: query_z: 10-D raw feature vector of the query run. confidence_threshold: below this score the result is flagged low-confidence. Returns: Dict with label, material, category, matches, margins, and decider="lda" — the same shape as identify().
594def seed_items(overwrite: bool = False): 595 """Insert baseline items into the items catalogue.""" 596 db = get_db() 597 for item in BASELINE_ITEMS: 598 if overwrite: 599 db.items.replace_one({"label": item["label"]}, item, upsert=True) 600 else: 601 db.items.update_one({"label": item["label"]}, {"$setOnInsert": item}, upsert=True) 602 print(f" seeded item: {item['label']}")
Insert baseline items into the items catalogue.