Edit on GitHub

atlas

View atlas.py on GitHub →

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.")
MONGODB_URI = $MONGODB_URI
DB_NAME = $MONGODB_DATABASE
VECTOR_INDEXES = {10: 'cup_dynamics_index', 2304: 'cup_dynamics_chronos_index'}
def get_db():
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.

def setup_collections():
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.

def ensure_vector_indexes(which='all'):
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"

def teach( label: str, feature_vector: list[float], raw_data: list[float] | None = None, motion_type: str = '3in1_macro', properties: dict | None = None, session: str = 'thu') -> dict:
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

MATERIAL_STEMS = {'water': 'water', 'soap': 'soap', 'dish_soap': 'soap', 'candy': 'candy', 'solid': 'solid', 'screws': 'screws', 'screw': 'screws', 'empty': 'empty', 'empty_cup': 'empty'}
def material_of(label: str) -> str:
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".

CATEGORY_OF_MATERIAL = {'water': 'liquid', 'soap': 'liquid', 'solid': 'solid', 'screws': 'solid', 'candy': 'solid', 'empty': 'empty'}
def category_of(label: str) -> str:
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.

def identify( feature_vector: list[float], top_k: int = 5, session: str | None = None, confidence_threshold: float = 0.82) -> dict:
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 }

EMPIRICAL_ACCURACY = {'label': 0.43, 'material': 0.43, 'category': 0.52, 'binary': 0.86}
def predict_lda(query_z, confidence_threshold=0.82):
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().

BASELINE_ITEMS = [{'label': 'empty_cup', 'description': 'Empty lidded coffee cup — baseline tare reference', 'category': 'empty', 'expected_signature': 'Low mass, near-zero shake resistance, no rattle'}, {'label': 'water_half', 'description': 'Half-full cup of water', 'category': 'newtonian_liquid', 'expected_signature': 'Medium mass, dynamic phase lag, low high-frequency rattle'}, {'label': 'dish_soap_half', 'description': 'Half-full cup of dish soap', 'category': 'viscous_liquid', 'expected_signature': 'Medium mass, heavy viscous damping, delayed peak torque'}, {'label': 'candy_loose', 'description': 'Halloween fun-size candy (loose pieces)', 'category': 'loose_solid', 'expected_signature': 'Sharp high-frequency impact chatter (5–25 Hz rattle peaks)'}, {'label': 'solid_weight', 'description': 'Small metal tool or solid roll of tape', 'category': 'rigid_solid', 'expected_signature': 'High mass, rigid coupling, zero rattle, zero phase lag'}]
def seed_items(overwrite: bool = False):
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.