a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

add cosmik collection models + tag-to-tag edges in memory graph

types.py: add StrongRef, CosmikCollection, CosmikCollectionLink with to_record()
namespace_memory.py: get_graph_data() reads phi-tag-relationships namespace to
inject tag-to-tag edges into the graph visualization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz bad0c75a 4dd13147

+171 -6
+5 -1
loq.toml
··· 17 17 18 18 [[rules]] 19 19 path = "src/bot/memory/namespace_memory.py" 20 - max_lines = 718 20 + max_lines = 836 21 + 22 + [[rules]] 23 + path = "src/bot/main.py" 24 + max_lines = 679
+122 -4
src/bot/memory/namespace_memory.py
··· 3 3 import asyncio 4 4 import hashlib 5 5 import logging 6 + import math 7 + import random 6 8 from datetime import datetime 7 9 from typing import ClassVar 8 10 ··· 609 611 ) 610 612 return user_results + episodic_results 611 613 614 + @staticmethod 615 + def _project_2d( 616 + centroids: dict[str, list[float]], 617 + ) -> dict[str, tuple[float, float]]: 618 + """Project high-dimensional centroids to 2D via fixed random projection.""" 619 + if not centroids: 620 + return {} 621 + dim = len(next(iter(centroids.values()))) 622 + rng = random.Random(42) 623 + axis_a = [rng.gauss(0, 1) for _ in range(dim)] 624 + axis_b = [rng.gauss(0, 1) for _ in range(dim)] 625 + # normalize axes 626 + norm_a = math.sqrt(sum(v * v for v in axis_a)) 627 + norm_b = math.sqrt(sum(v * v for v in axis_b)) 628 + axis_a = [v / norm_a for v in axis_a] 629 + axis_b = [v / norm_b for v in axis_b] 630 + 631 + raw: dict[str, tuple[float, float]] = {} 632 + for nid, vec in centroids.items(): 633 + x = sum(a * b for a, b in zip(axis_a, vec)) 634 + y = sum(a * b for a, b in zip(axis_b, vec)) 635 + raw[nid] = (x, y) 636 + 637 + if not raw: 638 + return {} 639 + xs = [p[0] for p in raw.values()] 640 + ys = [p[1] for p in raw.values()] 641 + x_min, x_max = min(xs), max(xs) 642 + y_min, y_max = min(ys), max(ys) 643 + x_span = x_max - x_min or 1.0 644 + y_span = y_max - y_min or 1.0 645 + return { 646 + nid: (2 * (p[0] - x_min) / x_span - 1, 2 * (p[1] - y_min) / y_span - 1) 647 + for nid, p in raw.items() 648 + } 649 + 612 650 def get_graph_data(self) -> dict: 613 - """Build graph nodes and edges from memory namespaces (sync, no embeddings needed).""" 651 + """Build graph nodes and edges from memory namespaces with semantic coordinates.""" 614 652 nodes = [{"id": "phi", "label": "phi", "type": "phi"}] 615 653 edges = [] 616 654 tag_set: set[str] = set() 617 655 user_tags: dict[str, set[str]] = {} # handle -> tags 656 + # vectors for computing semantic positions 657 + tag_vectors: dict[str, list[list[float]]] = {} 658 + user_vectors: dict[str, list[list[float]]] = {} 618 659 619 660 # discover user namespaces 620 661 user_prefix = f"{self.NAMESPACES['users']}-" ··· 627 668 ) 628 669 edges.append({"source": "phi", "target": f"user:{handle}"}) 629 670 630 - # get observations for this user to extract tags 671 + # get observations for this user to extract tags + vectors 631 672 user_ns = self.client.namespace(ns_summary.id) 632 673 try: 633 674 response = user_ns.query( 634 675 rank_by=("vector", "ANN", [0.5] * 1536), 635 676 top_k=50, 636 677 filters={"kind": ["Eq", "observation"]}, 637 - include_attributes=["tags"], 678 + include_attributes=["tags", "vector"], 638 679 ) 639 680 if response.rows: 640 681 for row in response.rows: 682 + vec = getattr(row, "vector", None) 641 683 for tag in getattr(row, "tags", []) or []: 642 684 tag_set.add(tag) 643 685 user_tags.setdefault(handle, set()).add(tag) 686 + if vec: 687 + tag_vectors.setdefault(tag, []).append(vec) 688 + if vec: 689 + user_vectors.setdefault(handle, []).append(vec) 644 690 except Exception: 645 691 pass # old namespace or no observations 646 692 except Exception as e: ··· 655 701 656 702 # episodic memories — group by top tags 657 703 episodic_tags: set[str] = set() 704 + episodic_vectors: dict[str, list[list[float]]] = {} 658 705 try: 659 706 response = self.namespaces["episodic"].query( 660 707 rank_by=("vector", "ANN", [0.5] * 1536), 661 708 top_k=100, 662 - include_attributes=["tags"], 709 + include_attributes=["tags", "vector"], 663 710 ) 664 711 if response.rows: 665 712 for row in response.rows: 713 + vec = getattr(row, "vector", None) 666 714 for tag in getattr(row, "tags", []) or []: 667 715 episodic_tags.add(tag) 716 + if vec: 717 + episodic_vectors.setdefault(tag, []).append(vec) 668 718 except Exception: 669 719 pass 670 720 ··· 675 725 # bridge to user tags if shared 676 726 if tag in tag_set: 677 727 edges.append({"source": f"tag:{tag}", "target": node_id}) 728 + 729 + # read tag-to-tag relationships from phi-tag-relationships 730 + node_ids = {n["id"] for n in nodes} 731 + try: 732 + rel_ns = self.client.namespace("phi-tag-relationships") 733 + rel_response = rel_ns.query( 734 + rank_by=("vector", "ANN", [0.5] * 1536), 735 + top_k=200, 736 + include_attributes=[ 737 + "tag_a", 738 + "tag_b", 739 + "relationship_type", 740 + "confidence", 741 + ], 742 + ) 743 + if rel_response.rows: 744 + for row in rel_response.rows: 745 + tag_a = getattr(row, "tag_a", "") 746 + tag_b = getattr(row, "tag_b", "") 747 + if not tag_a or not tag_b: 748 + continue 749 + # resolve to existing node IDs (prefer tag: over episodic:) 750 + source = ( 751 + f"tag:{tag_a}" 752 + if f"tag:{tag_a}" in node_ids 753 + else f"episodic:{tag_a}" 754 + if f"episodic:{tag_a}" in node_ids 755 + else None 756 + ) 757 + target = ( 758 + f"tag:{tag_b}" 759 + if f"tag:{tag_b}" in node_ids 760 + else f"episodic:{tag_b}" 761 + if f"episodic:{tag_b}" in node_ids 762 + else None 763 + ) 764 + if source and target and source != target: 765 + edges.append({"source": source, "target": target}) 766 + except Exception: 767 + pass # namespace may not exist yet 768 + 769 + # compute per-node embedding centroids 770 + def _centroid(vecs: list[list[float]]) -> list[float]: 771 + n = len(vecs) 772 + dim = len(vecs[0]) 773 + return [sum(v[i] for v in vecs) / n for i in range(dim)] 774 + 775 + centroids: dict[str, list[float]] = {} 776 + for tag, vecs in tag_vectors.items(): 777 + centroids[f"tag:{tag}"] = _centroid(vecs) 778 + for handle, vecs in user_vectors.items(): 779 + centroids[f"user:{handle}"] = _centroid(vecs) 780 + for tag, vecs in episodic_vectors.items(): 781 + centroids[f"episodic:{tag}"] = _centroid(vecs) 782 + 783 + coords = self._project_2d(centroids) 784 + 785 + for node in nodes: 786 + nid = node["id"] 787 + if nid == "phi": 788 + node["x"] = 0.0 789 + node["y"] = 0.0 790 + elif nid in coords: 791 + node["x"] = round(coords[nid][0], 4) 792 + node["y"] = round(coords[nid][1], 4) 793 + else: 794 + node["x"] = None 795 + node["y"] = None 678 796 679 797 return {"nodes": nodes, "edges": edges} 680 798
+44 -1
src/bot/types.py
··· 4 4 5 5 from pydantic import AfterValidator, BaseModel, Field 6 6 7 - 8 7 # --- validators --- 9 8 10 9 ··· 109 108 if self.content.description: 110 109 record["content"]["description"] = self.content.description 111 110 return record 111 + 112 + 113 + class StrongRef(BaseModel): 114 + """AT Protocol strong reference — uri + cid pair.""" 115 + 116 + uri: EntityRef 117 + cid: str 118 + 119 + 120 + class CosmikCollection(BaseModel): 121 + """network.cosmik.collection record — a named grouping of cards. 122 + 123 + Schema: at://cosmik.network/com.atproto.lexicon.schema/network.cosmik.collection 124 + """ 125 + 126 + name: str = Field(max_length=100) 127 + access_type: Literal["OPEN", "CLOSED"] = "OPEN" 128 + description: str | None = Field(default=None, max_length=500) 129 + 130 + def to_record(self) -> dict: 131 + record: dict = {"name": self.name, "accessType": self.access_type} 132 + if self.description: 133 + record["description"] = self.description 134 + return record 135 + 136 + 137 + class CosmikCollectionLink(BaseModel): 138 + """network.cosmik.collectionLink record — joins a card to a collection. 139 + 140 + Requires strongRefs (uri + cid) for both collection and card. 141 + """ 142 + 143 + collection: StrongRef 144 + card: StrongRef 145 + added_by: str 146 + added_at: str 147 + 148 + def to_record(self) -> dict: 149 + return { 150 + "collection": {"uri": self.collection.uri, "cid": self.collection.cid}, 151 + "card": {"uri": self.card.uri, "cid": self.card.cid}, 152 + "addedBy": self.added_by, 153 + "addedAt": self.added_at, 154 + }