repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | """ |
| #2 | Mnemosyne Episodic Gist+Fact Graph |
| #3 | ==================================== |
| #4 | Building on REMem (ICLR 2026, arXiv:2602.13530) |
| #5 | |
| #6 | Two-phase episodic memory: |
| #7 | 1. Gist extraction: concise episode summaries with temporal anchors |
| #8 | 2. Fact extraction: structured (subject, predicate, object) triples |
| #9 | |
| #10 | Graph structure: |
| #11 | - Nodes: V_gist (episodes) ∪ V_phrase (concepts) |
| #12 | - Edges: E_rel (relations) ∪ E_ctx (context) ∪ E_syn (synonymy) |
| #13 | - Temporal qualifiers: point_in_time, start_time, end_time |
| #14 | |
| #15 | Zero LLM calls for gist extraction (rule-based). |
| #16 | Zero LLM calls for fact extraction (pattern-based). |
| #17 | """ |
| #18 | |
| #19 | import re |
| #20 | import json |
| #21 | import sqlite3 |
| #22 | from datetime import datetime |
| #23 | from typing import Dict, List, Tuple, Optional, Set |
| #24 | from dataclasses import dataclass, asdict |
| #25 | from pathlib import Path |
| #26 | |
| #27 | |
| #28 | @dataclass |
| #29 | class Gist: |
| #30 | """Time-aware episode summary.""" |
| #31 | id: str |
| #32 | text: str |
| #33 | timestamp: str |
| #34 | participants: List[str] |
| #35 | location: Optional[str] |
| #36 | emotion: Optional[str] |
| #37 | time_scope: Optional[str] # point_in_time, start_time, end_time |
| #38 | |
| #39 | |
| #40 | @dataclass |
| #41 | class Fact: |
| #42 | """Structured fact triple.""" |
| #43 | id: str |
| #44 | subject: str |
| #45 | predicate: str |
| #46 | object: str |
| #47 | timestamp: str |
| #48 | confidence: float |
| #49 | temporal_qualifier: Optional[str] = None |
| #50 | |
| #51 | |
| #52 | @dataclass |
| #53 | class GraphEdge: |
| #54 | """Edge in the memory graph.""" |
| #55 | source: str |
| #56 | target: str |
| #57 | edge_type: str # rel, ctx, syn |
| #58 | weight: float |
| #59 | timestamp: str |
| #60 | |
| #61 | |
| #62 | class EpisodicGraph: |
| #63 | """ |
| #64 | Hybrid memory graph for episodic + semantic storage. |
| #65 | |
| #66 | Stores in SQLite: |
| #67 | - gists table: episode summaries |
| #68 | - facts table: structured triples |
| #69 | - graph_edges table: relationships between nodes |
| #70 | """ |
| #71 | |
| #72 | def __init__(self, db_path: Path = None, conn=None): |
| #73 | if conn is not None: |
| #74 | self.conn = conn |
| #75 | self.db_path = db_path or Path(":memory:") |
| #76 | else: |
| #77 | self.db_path = db_path or Path.home() / ".hermes" / "mnemosyne" / "data" / "mnemosyne.db" |
| #78 | self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) |
| #79 | self.conn.row_factory = sqlite3.Row |
| #80 | self._owns_connection = conn is None |
| #81 | self._init_tables() |
| #82 | |
| #83 | def _init_tables(self): |
| #84 | """Initialize episodic graph schema.""" |
| #85 | cursor = self.conn.cursor() |
| #86 | |
| #87 | # Gists table |
| #88 | cursor.execute(""" |
| #89 | CREATE TABLE IF NOT EXISTS gists ( |
| #90 | id TEXT PRIMARY KEY, |
| #91 | text TEXT NOT NULL, |
| #92 | timestamp TEXT, |
| #93 | participants_json TEXT, |
| #94 | location TEXT, |
| #95 | emotion TEXT, |
| #96 | time_scope TEXT, |
| #97 | memory_id TEXT, |
| #98 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #99 | ) |
| #100 | """) |
| #101 | |
| #102 | # Facts table (compatible with beam.py schema) |
| #103 | cursor.execute(""" |
| #104 | CREATE TABLE IF NOT EXISTS facts ( |
| #105 | fact_id TEXT PRIMARY KEY, |
| #106 | session_id TEXT DEFAULT 'default', |
| #107 | subject TEXT NOT NULL, |
| #108 | predicate TEXT NOT NULL, |
| #109 | object TEXT NOT NULL, |
| #110 | timestamp TEXT, |
| #111 | source_msg_id TEXT, |
| #112 | confidence REAL DEFAULT 0.5, |
| #113 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #114 | ) |
| #115 | """) |
| #116 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject)") |
| #117 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_facts_predicate ON facts(predicate)") |
| #118 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_facts_object ON facts(object)") |
| #119 | |
| #120 | # Graph edges table |
| #121 | cursor.execute(""" |
| #122 | CREATE TABLE IF NOT EXISTS graph_edges ( |
| #123 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| #124 | source TEXT NOT NULL, |
| #125 | target TEXT NOT NULL, |
| #126 | edge_type TEXT NOT NULL, |
| #127 | weight REAL DEFAULT 1.0, |
| #128 | timestamp TEXT, |
| #129 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
| #130 | ) |
| #131 | """) |
| #132 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_edges_source ON graph_edges(source)") |
| #133 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_edges_target ON graph_edges(target)") |
| #134 | cursor.execute("CREATE INDEX IF NOT EXISTS idx_edges_type ON graph_edges(edge_type)") |
| #135 | |
| #136 | self.conn.commit() |
| #137 | |
| #138 | # --- Gist Extraction (Rule-based, zero LLM) --- |
| #139 | |
| #140 | def extract_gist(self, content: str, memory_id: str) -> Gist: |
| #141 | """ |
| #142 | Extract episodic gist from raw content. |
| #143 | |
| #144 | Rule-based extraction of: |
| #145 | - Participants (names, pronouns) |
| #146 | - Temporal anchors (dates, times, relative references) |
| #147 | - Location (place references) |
| #148 | - Emotion (sentiment indicators) |
| #149 | |
| #150 | Args: |
| #151 | content: Raw memory text |
| #152 | memory_id: Source memory ID |
| #153 | |
| #154 | Returns: |
| #155 | Gist object |
| #156 | """ |
| #157 | content_lower = content.lower() |
| #158 | |
| #159 | # Extract participants |
| #160 | participants = self._extract_participants(content) |
| #161 | |
| #162 | # Extract temporal scope |
| #163 | time_scope = self._extract_temporal_scope(content) |
| #164 | |
| #165 | # Extract location |
| #166 | location = self._extract_location(content) |
| #167 | |
| #168 | # Extract emotion |
| #169 | emotion = self._extract_emotion(content) |
| #170 | |
| #171 | # Create concise summary (first sentence or first 100 chars) |
| #172 | summary = self._create_summary(content) |
| #173 | |
| #174 | return Gist( |
| #175 | id=f"gist_{memory_id}", |
| #176 | text=summary, |
| #177 | timestamp=datetime.now().isoformat(), |
| #178 | participants=participants, |
| #179 | location=location, |
| #180 | emotion=emotion, |
| #181 | time_scope=time_scope |
| #182 | ) |
| #183 | |
| #184 | def _extract_participants(self, content: str) -> List[str]: |
| #185 | """Extract participant names and pronouns.""" |
| #186 | # Common name patterns |
| #187 | name_pattern = r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\b" |
| #188 | names = re.findall(name_pattern, content) |
| #189 | |
| #190 | # Pronouns |
| #191 | pronoun_pattern = r"\b(I|you|we|they|he|she|it|me|us|them|him|her)\b" |
| #192 | pronouns = re.findall(pronoun_pattern, content, re.IGNORECASE) |
| #193 | |
| #194 | # Combine and deduplicate |
| #195 | participants = list(set(names + pronouns)) |
| #196 | return participants[:5] # Cap at 5 |
| #197 | |
| #198 | def _extract_temporal_scope(self, content: str) -> Optional[str]: |
| #199 | """Extract temporal references.""" |
| #200 | temporal_patterns = [ |
| #201 | (r"\b(yesterday|today|tomorrow|now|soon|later|earlier)\b", "point_in_time"), |
| #202 | (r"\b(last\s+week|last\s+month|last\s+year|next\s+week)\b", "point_in_time"), |
| #203 | (r"\b(since|from|starting)\b.*\b(until|to|through|end)\b", "duration"), |
| #204 | (r"\b(between|from)\b.*\b(and|to)\b", "range"), |
| #205 | (r"\b\d{1,2}:\d{2}\s*(AM|PM|am|pm)?\b", "point_in_time"), |
| #206 | (r"\b\d{4}-\d{2}-\d{2}\b", "point_in_time"), |
| #207 | ] |
| #208 | |
| #209 | for pattern, scope_type in temporal_patterns: |
| #210 | if re.search(pattern, content, re.IGNORECASE): |
| #211 | return scope_type |
| #212 | |
| #213 | return None |
| #214 | |
| #215 | def _extract_location(self, content: str) -> Optional[str]: |
| #216 | """Extract location references.""" |
| #217 | location_patterns = [ |
| #218 | r"\b(at|in|from)\s+([A-Z][a-zA-Z\s]+?)(?:\s+(?:yesterday|today|tomorrow|now|last|next|on|at)\b|$)", |
| #219 | r"\b(office|home|work|school|hospital|store|restaurant|building|room)\b", |
| #220 | ] |
| #221 | |
| #222 | for pattern in location_patterns: |
| #223 | match = re.search(pattern, content, re.IGNORECASE) |
| #224 | if match: |
| #225 | return match.group(2) if len(match.groups()) > 1 else match.group(1) |
| #226 | |
| #227 | return None |
| #228 | |
| #229 | def _extract_emotion(self, content: str) -> Optional[str]: |
| #230 | """Extract emotional indicators.""" |
| #231 | emotion_words = { |
| #232 | "positive": ["happy", "excited", "great", "awesome", "love", "enjoy", "glad", "pleased"], |
| #233 | "negative": ["sad", "angry", "frustrated", "upset", "hate", "disappointed", "worried"], |
| #234 | "neutral": ["fine", "okay", "alright", "normal", "standard"], |
| #235 | } |
| #236 | |
| #237 | content_lower = content.lower() |
| #238 | for emotion_type, words in emotion_words.items(): |
| #239 | if any(word in content_lower for word in words): |
| #240 | return emotion_type |
| #241 | |
| #242 | return None |
| #243 | |
| #244 | def _create_summary(self, content: str) -> str: |
| #245 | """Create concise episode summary.""" |
| #246 | # Take first sentence or first 100 chars |
| #247 | sentences = re.split(r'[.!?]+', content) |
| #248 | if sentences and len(sentences[0]) > 10: |
| #249 | return sentences[0].strip()[:100] |
| #250 | return content[:100].strip() |
| #251 | |
| #252 | # --- Fact Extraction (Rule-based, zero LLM) --- |
| #253 | |
| #254 | def extract_facts(self, content: str, memory_id: str) -> List[Fact]: |
| #255 | """ |
| #256 | Extract structured facts from content. |
| #257 | |
| #258 | Pattern-based extraction of (subject, predicate, object) triples. |
| #259 | |
| #260 | Args: |
| #261 | content: Raw memory text |
| #262 | memory_id: Source memory ID |
| #263 | |
| #264 | Returns: |
| #265 | List of Fact objects |
| #266 | """ |
| #267 | facts = [] |
| #268 | |
| #269 | # Pattern 1: "X is Y" |
| #270 | is_pattern = r"\b([A-Z][a-zA-Z\s]+?)\s+is\s+(?:a|an|the)?\s*([a-zA-Z\s]+?)\b" |
| #271 | for match in re.finditer(is_pattern, content): |
| #272 | subject = match.group(1).strip() |
| #273 | obj = match.group(2).strip() |
| #274 | if len(subject) > 2 and len(obj) > 2: |
| #275 | facts.append(Fact( |
| #276 | id=f"fact_{memory_id}_{len(facts)}", |
| #277 | subject=subject, |
| #278 | predicate="is", |
| #279 | object=obj, |
| #280 | timestamp=datetime.now().isoformat(), |
| #281 | confidence=0.7 |
| #282 | )) |
| #283 | |
| #284 | # Pattern 2: "X has Y" |
| #285 | has_pattern = r"\b([A-Z][a-zA-Z\s]+?)\s+has\s+(?:a|an|the)?\s*([a-zA-Z\d\s]+?)\b" |
| #286 | for match in re.finditer(has_pattern, content): |
| #287 | subject = match.group(1).strip() |
| #288 | obj = match.group(2).strip() |
| #289 | if len(subject) > 2 and len(obj) > 2: |
| #290 | facts.append(Fact( |
| #291 | id=f"fact_{memory_id}_{len(facts)}", |
| #292 | subject=subject, |
| #293 | predicate="has", |
| #294 | object=obj, |
| #295 | timestamp=datetime.now().isoformat(), |
| #296 | confidence=0.6 |
| #297 | )) |
| #298 | |
| #299 | # Pattern 3: "X uses Y" |
| #300 | uses_pattern = r"\b([A-Z][a-zA-Z\s]+?)\s+(uses?|using|used)\s+(?:a|an|the)?\s*([a-zA-Z\s]+?)\b" |
| #301 | for match in re.finditer(uses_pattern, content): |
| #302 | subject = match.group(1).strip() |
| #303 | obj = match.group(3).strip() |
| #304 | if len(subject) > 2 and len(obj) > 2: |
| #305 | facts.append(Fact( |
| #306 | id=f"fact_{memory_id}_{len(facts)}", |
| #307 | subject=subject, |
| #308 | predicate="uses", |
| #309 | object=obj, |
| #310 | timestamp=datetime.now().isoformat(), |
| #311 | confidence=0.6 |
| #312 | )) |
| #313 | |
| #314 | # Pattern 4: "X works at Y" |
| #315 | works_pattern = r"\b([A-Z][a-zA-Z\s]+?)\s+works?\s+(?:at|for|with)\s+([A-Z][a-zA-Z\s]+?)\b" |
| #316 | for match in re.finditer(works_pattern, content): |
| #317 | subject = match.group(1).strip() |
| #318 | obj = match.group(2).strip() |
| #319 | if len(subject) > 2 and len(obj) > 2: |
| #320 | facts.append(Fact( |
| #321 | id=f"fact_{memory_id}_{len(facts)}", |
| #322 | subject=subject, |
| #323 | predicate="works_at", |
| #324 | object=obj, |
| #325 | timestamp=datetime.now().isoformat(), |
| #326 | confidence=0.7 |
| #327 | )) |
| #328 | |
| #329 | return facts[:5] # Cap at 5 facts per memory |
| #330 | |
| #331 | # --- Graph Storage --- |
| #332 | |
| #333 | def store_gist(self, gist: Gist, memory_id: str): |
| #334 | """Store a gist in the database.""" |
| #335 | cursor = self.conn.cursor() |
| #336 | cursor.execute(""" |
| #337 | INSERT OR REPLACE INTO gists |
| #338 | (id, text, timestamp, participants_json, location, emotion, time_scope, memory_id) |
| #339 | VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| #340 | """, ( |
| #341 | gist.id, |
| #342 | gist.text, |
| #343 | gist.timestamp, |
| #344 | json.dumps(gist.participants), |
| #345 | gist.location, |
| #346 | gist.emotion, |
| #347 | gist.time_scope, |
| #348 | memory_id |
| #349 | )) |
| #350 | self.conn.commit() |
| #351 | |
| #352 | def store_fact(self, fact: Fact, memory_id: str, session_id: str = "default"): |
| #353 | """Store a fact in the database.""" |
| #354 | cursor = self.conn.cursor() |
| #355 | cursor.execute(""" |
| #356 | INSERT OR REPLACE INTO facts |
| #357 | (fact_id, session_id, subject, predicate, object, timestamp, source_msg_id, confidence) |
| #358 | VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| #359 | """, ( |
| #360 | fact.id, |
| #361 | session_id, |
| #362 | fact.subject, |
| #363 | fact.predicate, |
| #364 | fact.object, |
| #365 | fact.timestamp, |
| #366 | memory_id, |
| #367 | fact.confidence |
| #368 | )) |
| #369 | self.conn.commit() |
| #370 | |
| #371 | def add_edge(self, edge: GraphEdge): |
| #372 | """Add an edge to the graph.""" |
| #373 | cursor = self.conn.cursor() |
| #374 | cursor.execute(""" |
| #375 | INSERT OR REPLACE INTO graph_edges |
| #376 | (source, target, edge_type, weight, timestamp) |
| #377 | VALUES (?, ?, ?, ?, ?) |
| #378 | """, ( |
| #379 | edge.source, |
| #380 | edge.target, |
| #381 | edge.edge_type, |
| #382 | edge.weight, |
| #383 | edge.timestamp |
| #384 | )) |
| #385 | self.conn.commit() |
| #386 | |
| #387 | # --- Graph Traversal --- |
| #388 | |
| #389 | def find_related_memories(self, memory_id: str, depth: int = 2) -> List[str]: |
| #390 | """ |
| #391 | Find memories related to a given memory via graph traversal. |
| #392 | |
| #393 | Args: |
| #394 | memory_id: Starting memory |
| #395 | depth: Traversal depth (default 2) |
| #396 | |
| #397 | Returns: |
| #398 | List of related memory IDs |
| #399 | """ |
| #400 | related = set() |
| #401 | current_level = {memory_id} |
| #402 | |
| #403 | for _ in range(depth): |
| #404 | next_level = set() |
| #405 | for mem in current_level: |
| #406 | cursor = self.conn.cursor() |
| #407 | cursor.execute(""" |
| #408 | SELECT source, target FROM graph_edges |
| #409 | WHERE source = ? OR target = ? |
| #410 | """, (mem, mem)) |
| #411 | |
| #412 | for row in cursor.fetchall(): |
| #413 | next_level.add(row["source"]) |
| #414 | next_level.add(row["target"]) |
| #415 | |
| #416 | related.update(next_level) |
| #417 | current_level = next_level |
| #418 | |
| #419 | related.discard(memory_id) |
| #420 | return list(related) |
| #421 | |
| #422 | def find_facts_by_subject(self, subject: str) -> List[Fact]: |
| #423 | """Find all facts about a subject.""" |
| #424 | cursor = self.conn.cursor() |
| #425 | cursor.execute(""" |
| #426 | SELECT * FROM facts WHERE subject = ? |
| #427 | ORDER BY confidence DESC, timestamp DESC |
| #428 | """, (subject,)) |
| #429 | |
| #430 | facts = [] |
| #431 | for row in cursor.fetchall(): |
| #432 | facts.append(Fact( |
| #433 | id=row["fact_id"], |
| #434 | subject=row["subject"], |
| #435 | predicate=row["predicate"], |
| #436 | object=row["object"], |
| #437 | timestamp=row["timestamp"], |
| #438 | confidence=row["confidence"], |
| #439 | temporal_qualifier=None # Not in beam.py facts schema |
| #440 | )) |
| #441 | |
| #442 | return facts |
| #443 | |
| #444 | def find_gists_by_participant(self, participant: str) -> List[Gist]: |
| #445 | """Find all gists involving a participant.""" |
| #446 | cursor = self.conn.cursor() |
| #447 | cursor.execute(""" |
| #448 | SELECT * FROM gists |
| #449 | WHERE participants_json LIKE ? |
| #450 | ORDER BY timestamp DESC |
| #451 | """, (f'%"{participant}"%',)) |
| #452 | |
| #453 | gists = [] |
| #454 | for row in cursor.fetchall(): |
| #455 | gists.append(Gist( |
| #456 | id=row["id"], |
| #457 | text=row["text"], |
| #458 | timestamp=row["timestamp"], |
| #459 | participants=json.loads(row["participants_json"]), |
| #460 | location=row["location"], |
| #461 | emotion=row["emotion"], |
| #462 | time_scope=row["time_scope"] |
| #463 | )) |
| #464 | |
| #465 | return gists |
| #466 | |
| #467 | def get_stats(self) -> Dict: |
| #468 | """Get graph statistics.""" |
| #469 | cursor = self.conn.cursor() |
| #470 | |
| #471 | cursor.execute("SELECT COUNT(*) FROM gists") |
| #472 | gist_count = cursor.fetchone()[0] |
| #473 | |
| #474 | cursor.execute("SELECT COUNT(*) FROM facts") |
| #475 | fact_count = cursor.fetchone()[0] |
| #476 | |
| #477 | cursor.execute("SELECT COUNT(*) FROM graph_edges") |
| #478 | edge_count = cursor.fetchone()[0] |
| #479 | |
| #480 | return { |
| #481 | "gists": gist_count, |
| #482 | "facts": fact_count, |
| #483 | "edges": edge_count, |
| #484 | "total_nodes": gist_count + fact_count, |
| #485 | } |
| #486 | |
| #487 | def close(self): |
| #488 | """Close database connection.""" |
| #489 | self.conn.close() |
| #490 | |
| #491 | |
| #492 | # --- Testing --- |
| #493 | if __name__ == "__main__": |
| #494 | import tempfile |
| #495 | import os |
| #496 | |
| #497 | print("Episodic Graph Tests") |
| #498 | print("=" * 60) |
| #499 | |
| #500 | # Create temp database |
| #501 | with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: |
| #502 | db_path = f.name |
| #503 | |
| #504 | graph = EpisodicGraph(db_path=Path(db_path)) |
| #505 | |
| #506 | # Test content |
| #507 | test_content = """ |
| #508 | Alice had a meeting with Bob yesterday at the office. |
| #509 | She was excited about the new project. |
| #510 | Alice is a senior developer at TechCorp. |
| #511 | She uses Python for backend development. |
| #512 | The deadline is next Friday. |
| #513 | """ |
| #514 | |
| #515 | # Extract gist |
| #516 | gist = graph.extract_gist(test_content, "mem_001") |
| #517 | print(f"Gist: {gist.text}") |
| #518 | print(f" Participants: {gist.participants}") |
| #519 | print(f" Location: {gist.location}") |
| #520 | print(f" Emotion: {gist.emotion}") |
| #521 | print(f" Time scope: {gist.time_scope}") |
| #522 | |
| #523 | # Store gist |
| #524 | graph.store_gist(gist, "mem_001") |
| #525 | |
| #526 | # Extract facts |
| #527 | facts = graph.extract_facts(test_content, "mem_001") |
| #528 | print(f"\nFacts extracted: {len(facts)}") |
| #529 | for fact in facts: |
| #530 | print(f" {fact.subject} --{fact.predicate}--> {fact.object} (conf: {fact.confidence})") |
| #531 | graph.store_fact(fact, "mem_001") |
| #532 | |
| #533 | # Add edges |
| #534 | graph.add_edge(GraphEdge("mem_001", "mem_002", "rel", 0.8, datetime.now().isoformat())) |
| #535 | graph.add_edge(GraphEdge("mem_001", "mem_003", "ctx", 0.6, datetime.now().isoformat())) |
| #536 | |
| #537 | # Find related |
| #538 | related = graph.find_related_memories("mem_001", depth=1) |
| #539 | print(f"\nRelated memories: {related}") |
| #540 | |
| #541 | # Find facts by subject |
| #542 | alice_facts = graph.find_facts_by_subject("Alice") |
| #543 | print(f"\nFacts about Alice: {len(alice_facts)}") |
| #544 | |
| #545 | # Stats |
| #546 | stats = graph.get_stats() |
| #547 | print(f"\nGraph stats: {stats}") |
| #548 | |
| #549 | # Cleanup |
| #550 | graph.close() |
| #551 | os.unlink(db_path) |
| #552 | |
| #553 | print("\n" + "=" * 60) |
| #554 | print("Episodic graph tests passed!") |
| #555 |