graph-mind-memory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- associative_memory.py +507 -0
- automatic_capture.py +288 -0
- automatic_capture_cli.py +72 -0
- brain_log.py +501 -0
- coding_memory.py +438 -0
- concept_plan_benchmark_v071.py +248 -0
- conversation_memory.py +476 -0
- development_memory.py +450 -0
- development_paths.py +40 -0
- embedding_warmup.py +171 -0
- entity_timeline.py +130 -0
- executable_memory.py +312 -0
- executable_plan_v2_diagnostic.py +272 -0
- graph_mind_mcp_server.py +864 -0
- graph_mind_memory-0.1.0.dist-info/METADATA +252 -0
- graph_mind_memory-0.1.0.dist-info/RECORD +43 -0
- graph_mind_memory-0.1.0.dist-info/WHEEL +5 -0
- graph_mind_memory-0.1.0.dist-info/entry_points.txt +4 -0
- graph_mind_memory-0.1.0.dist-info/licenses/LICENSE +661 -0
- graph_mind_memory-0.1.0.dist-info/top_level.txt +37 -0
- grounded_concept_bridge_v072.py +511 -0
- ingestion_gate.py +113 -0
- ingestion_synonyms.py +165 -0
- install.py +198 -0
- llm_concept_planner.py +170 -0
- local_brain.py +354 -0
- longmemeval_adapter.py +120 -0
- longmemeval_ingestion_calibration.py +366 -0
- longmemeval_ingestion_preflight.py +188 -0
- longmemeval_ingestion_v7.py +356 -0
- memory_algebra.py +155 -0
- memory_contract.py +154 -0
- memory_normalization.py +13 -0
- memory_vocabulary.py +34 -0
- semantic_grounding_v073.py +501 -0
- semantic_llm_extractor.py +215 -0
- semantic_memory.py +155 -0
- semantic_recall.py +100 -0
- state_relation_contract.py +158 -0
- temporal_resolver.py +272 -0
- temporal_state.py +210 -0
- universal_personal_memory.py +234 -0
- vector_cache.py +194 -0
entity_timeline.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Per-entity change history: "what happened to THIS thing, in order".
|
|
2
|
+
|
|
3
|
+
Retrieval by similarity answers "what is related to this question". It does not answer "what has
|
|
4
|
+
happened to the auth module", because the four decisions about it sit in four different sessions and
|
|
5
|
+
only the closest one or two come back. The failures that kept surviving every retrieval fix were of
|
|
6
|
+
exactly this shape — which came first, what is the current value, how many times in total.
|
|
7
|
+
|
|
8
|
+
So the store keeps a second index beside the memories: entity -> its memories, oldest first, with
|
|
9
|
+
superseded ones marked. No graph engine and no traversal: Zep's multi-hop machinery answers questions
|
|
10
|
+
we never actually failed. The entity NAMES come from the model that wrote the memory (naming is a
|
|
11
|
+
language judgement); this module only groups, orders and reads.
|
|
12
|
+
|
|
13
|
+
python entity_timeline.py # self-check on a temporary store
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
20
|
+
|
|
21
|
+
SCHEMA = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS entity_timeline(
|
|
23
|
+
entity_key TEXT NOT NULL,
|
|
24
|
+
entity_label TEXT NOT NULL,
|
|
25
|
+
memory_id TEXT NOT NULL,
|
|
26
|
+
effective_at TEXT NOT NULL,
|
|
27
|
+
PRIMARY KEY(entity_key, memory_id));
|
|
28
|
+
CREATE INDEX IF NOT EXISTS entity_timeline_order
|
|
29
|
+
ON entity_timeline(entity_key, effective_at);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS entity_timeline_state(
|
|
31
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize(name: str) -> str:
|
|
36
|
+
"""Group "Auth Module", "auth module" and "auth module." together — punctuation and spacing
|
|
37
|
+
only. Deciding that "인증 모듈" is the same entity is a language call and belongs to the model,
|
|
38
|
+
which can pass both names on the memory."""
|
|
39
|
+
return re.sub(r"[^\w가-힣]+", " ", str(name).casefold()).strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rebuild(db: sqlite3.Connection) -> int:
|
|
43
|
+
"""(Re)build the index when the memory count changed. Cheap: one pass over the memories."""
|
|
44
|
+
db.executescript(SCHEMA)
|
|
45
|
+
total = db.execute("SELECT COUNT(*) FROM local_brain_memories").fetchone()[0]
|
|
46
|
+
state = dict(db.execute("SELECT key, value FROM entity_timeline_state"))
|
|
47
|
+
if state.get("memories") == str(total):
|
|
48
|
+
return 0
|
|
49
|
+
rows = []
|
|
50
|
+
for memory_id, effective_at, entities_json in db.execute(
|
|
51
|
+
"SELECT memory_id, effective_at, entities_json FROM local_brain_memories"):
|
|
52
|
+
try:
|
|
53
|
+
entities = json.loads(entities_json) or []
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
entities = []
|
|
56
|
+
for entity in entities:
|
|
57
|
+
key = normalize(entity)
|
|
58
|
+
if key:
|
|
59
|
+
rows.append((key, str(entity), memory_id, effective_at))
|
|
60
|
+
with db:
|
|
61
|
+
db.execute("DELETE FROM entity_timeline")
|
|
62
|
+
db.executemany("INSERT OR REPLACE INTO entity_timeline VALUES(?,?,?,?)", rows)
|
|
63
|
+
db.execute("INSERT OR REPLACE INTO entity_timeline_state VALUES('memories',?)", (str(total),))
|
|
64
|
+
return len(rows)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def entities(db: sqlite3.Connection, limit: int = 50) -> list[dict]:
|
|
68
|
+
"""Every entity the store knows, busiest first — the map of what this memory is about."""
|
|
69
|
+
rebuild(db)
|
|
70
|
+
return [{"entity": label, "entries": count, "last_seen": last}
|
|
71
|
+
for label, count, last in db.execute(
|
|
72
|
+
# MIN() so a group reports one stable label instead of whichever spelling SQLite
|
|
73
|
+
# happened to read last ("auth module." vs "Auth Module").
|
|
74
|
+
"SELECT MIN(entity_label), COUNT(*) c, MAX(effective_at) FROM entity_timeline "
|
|
75
|
+
"GROUP BY entity_key ORDER BY c DESC LIMIT ?", (limit,))]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def timeline(db: sqlite3.Connection, entity: str, limit: int = 50) -> list[dict]:
|
|
79
|
+
"""This entity's memories oldest first, with the ones a later memory replaced marked."""
|
|
80
|
+
rebuild(db)
|
|
81
|
+
key = normalize(entity)
|
|
82
|
+
rows = db.execute(
|
|
83
|
+
"SELECT m.memory_id, m.effective_at, m.title, m.content, m.memory_type,"
|
|
84
|
+
" m.supersedes_memory_id "
|
|
85
|
+
"FROM entity_timeline t JOIN local_brain_memories m ON m.memory_id = t.memory_id "
|
|
86
|
+
"WHERE t.entity_key = ? OR t.entity_key LIKE ? "
|
|
87
|
+
"ORDER BY m.effective_at, m.revision LIMIT ?", (key, f"%{key}%", limit)).fetchall()
|
|
88
|
+
replaced = {row[5] for row in rows if row[5]}
|
|
89
|
+
return [{"memory_id": r[0], "effective_at": r[1], "title": r[2], "content": r[3],
|
|
90
|
+
"memory_type": r[4], "superseded": r[0] in replaced} for r in rows]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _self_check():
|
|
94
|
+
import tempfile
|
|
95
|
+
import uuid
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
from local_brain import LocalBrainStore
|
|
98
|
+
|
|
99
|
+
path = Path(tempfile.mkdtemp()) / "brain.sqlite"
|
|
100
|
+
facts = [("JWT expiry set to 24h", ["auth module"], "2026-03-12T09:00:00", None),
|
|
101
|
+
("Refresh tokens added", ["Auth Module", "mobile"], "2026-03-20T09:00:00", None),
|
|
102
|
+
("JWT expiry cut to 1h", ["auth module."], "2026-04-02T09:00:00", "m1"),
|
|
103
|
+
("Bought an air fryer", ["kitchen"], "2026-04-05T09:00:00", None)]
|
|
104
|
+
with LocalBrainStore(path) as store:
|
|
105
|
+
for i, (title, ents, when, supersedes) in enumerate(facts, start=1):
|
|
106
|
+
store.remember({"memory_id": f"m{i}", "scope": "global", "memory_type": "fact",
|
|
107
|
+
"title": title, "content": title, "effective_at": when,
|
|
108
|
+
"known_at": when, "actor": "assistant", "tags": [],
|
|
109
|
+
"entities": ents,
|
|
110
|
+
"provenance": {"source_type": "test", "source_ref": "unit"},
|
|
111
|
+
"supersedes_memory_id": supersedes})
|
|
112
|
+
db = sqlite3.connect(path)
|
|
113
|
+
assert rebuild(db) == 5, "4 memories, one with two entities"
|
|
114
|
+
assert rebuild(db) == 0, "no work when nothing changed"
|
|
115
|
+
|
|
116
|
+
rows = timeline(db, "AUTH MODULE")
|
|
117
|
+
assert [r["title"] for r in rows] == ["JWT expiry set to 24h", "Refresh tokens added",
|
|
118
|
+
"JWT expiry cut to 1h"], rows
|
|
119
|
+
assert rows[0]["superseded"] and not rows[-1]["superseded"], "m1 was replaced by m3"
|
|
120
|
+
assert timeline(db, "kitchen")[0]["title"] == "Bought an air fryer"
|
|
121
|
+
assert timeline(db, "nothing here") == []
|
|
122
|
+
names = {e["entity"].casefold() for e in entities(db)}
|
|
123
|
+
assert "auth module" in names and "kitchen" in names, names
|
|
124
|
+
assert entities(db)[0]["entries"] == 3, "auth module is the busiest entity"
|
|
125
|
+
db.close()
|
|
126
|
+
print("entity_timeline self-check ok")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
_self_check()
|
executable_memory.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Conservative deterministic execution of an external LLM's typed plan.
|
|
2
|
+
|
|
3
|
+
Lexical alternatives within a filter are OR; different filters are AND. No
|
|
4
|
+
synonyms, dates, occurrence IDs, or completeness certificates are invented.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import memory_algebra as algebra
|
|
13
|
+
from memory_normalization import normalize, normalize_unit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def participant_in_scope(participant, event, scope):
|
|
17
|
+
role = participant.get("source_role")
|
|
18
|
+
if role is None and len(event.get("source_roles", [])) == 1:
|
|
19
|
+
role = event["source_roles"][0]
|
|
20
|
+
return role in {"user", "assistant"} and role in event.get("source_roles", []) and (scope == "BOTH" or
|
|
21
|
+
(scope == "USER_MEMORY" and role == "user") or
|
|
22
|
+
(scope == "ASSISTANT_HISTORY" and role == "assistant"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def iso(value: object) -> datetime | None:
|
|
26
|
+
if not value or not re.fullmatch(r"\d{4}-\d{2}-\d{2}(?:T.*)?", str(value)):
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
return datetime.fromisoformat(str(value))
|
|
30
|
+
except ValueError:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TypedMemoryIndex:
|
|
35
|
+
def __init__(self, events: list[dict]):
|
|
36
|
+
self.events = events
|
|
37
|
+
self.by_relation: dict[str, list[dict]] = {}
|
|
38
|
+
self.by_participant: dict[tuple[str, str], set[int]] = {}
|
|
39
|
+
self.by_event_id: dict[str, int] = {}
|
|
40
|
+
for position, event in enumerate(events):
|
|
41
|
+
self.by_relation.setdefault(event["relation"], []).append(event)
|
|
42
|
+
if event.get("evidence_id"):
|
|
43
|
+
if event["evidence_id"] in self.by_event_id:
|
|
44
|
+
raise ValueError("DUPLICATE_EVIDENCE_ID")
|
|
45
|
+
self.by_event_id[event["evidence_id"]] = position
|
|
46
|
+
for participant in event.get("participants", []):
|
|
47
|
+
if participant.get("entity_id"):
|
|
48
|
+
self.by_participant.setdefault((participant["role"], participant["entity_id"]), set()).add(position)
|
|
49
|
+
|
|
50
|
+
def candidates(self, operand: dict) -> tuple[list[dict], dict]:
|
|
51
|
+
allowed = set(operand["relations"])
|
|
52
|
+
total_relation_rows = sum(len(self.by_relation.get(relation, [])) for relation in allowed)
|
|
53
|
+
if operand.get("participant_bindings") or operand.get("event_ids"):
|
|
54
|
+
positions = None
|
|
55
|
+
for binding in operand.get("participant_bindings", []):
|
|
56
|
+
hits = set().union(*(self.by_participant.get((binding["role"], eid), set())
|
|
57
|
+
for eid in binding["entity_ids"]))
|
|
58
|
+
positions = hits if positions is None else positions & hits
|
|
59
|
+
if operand.get("event_ids"):
|
|
60
|
+
hits = {self.by_event_id[eid] for eid in operand["event_ids"] if eid in self.by_event_id}
|
|
61
|
+
positions = hits if positions is None else positions & hits
|
|
62
|
+
relation_rows = [self.events[position] for position in sorted(positions or [])
|
|
63
|
+
if self.events[position]["relation"] in allowed]
|
|
64
|
+
candidate_source = "EVENT_ID_POSTINGS" if operand.get("event_ids") else "PARTICIPANT_ID_POSTINGS"
|
|
65
|
+
else:
|
|
66
|
+
relation_rows = [event for relation in dict.fromkeys(operand["relations"])
|
|
67
|
+
for event in self.by_relation.get(relation, [])]
|
|
68
|
+
candidate_source = "RELATION_SCAN"
|
|
69
|
+
rows = []
|
|
70
|
+
rejected = {"source": 0, "entity": 0, "detail": 0, "category": 0}
|
|
71
|
+
for event in relation_rows:
|
|
72
|
+
roles = set(event.get("source_roles") or [])
|
|
73
|
+
scope = operand["source_scope"]
|
|
74
|
+
if (not roles or (scope == "USER_MEMORY" and "user" not in roles)
|
|
75
|
+
or (scope == "ASSISTANT_HISTORY" and "assistant" not in roles)):
|
|
76
|
+
rejected["source"] += 1
|
|
77
|
+
continue
|
|
78
|
+
if operand.get("fact_modes") and event.get("fact_mode") not in operand["fact_modes"]:
|
|
79
|
+
rejected.setdefault("fact_mode", 0)
|
|
80
|
+
rejected["fact_mode"] += 1
|
|
81
|
+
continue
|
|
82
|
+
if operand.get("participant_terms") and not all(any(
|
|
83
|
+
participant.get("role") == constraint["role"]
|
|
84
|
+
and participant_in_scope(participant, event, scope)
|
|
85
|
+
and normalize(participant.get("name")) in {
|
|
86
|
+
normalize(name) for name in constraint["names"]}
|
|
87
|
+
for participant in event.get("participants", []))
|
|
88
|
+
for constraint in operand["participant_terms"]):
|
|
89
|
+
rejected.setdefault("participant", 0)
|
|
90
|
+
rejected["participant"] += 1
|
|
91
|
+
continue
|
|
92
|
+
if operand.get("participant_bindings") and not all(any(
|
|
93
|
+
participant.get("role") == constraint["role"]
|
|
94
|
+
and participant_in_scope(participant, event, scope)
|
|
95
|
+
and participant.get("entity_id") in constraint["entity_ids"]
|
|
96
|
+
for participant in event.get("participants", []))
|
|
97
|
+
for constraint in operand["participant_bindings"]):
|
|
98
|
+
rejected.setdefault("participant_id", 0)
|
|
99
|
+
rejected["participant_id"] += 1
|
|
100
|
+
continue
|
|
101
|
+
text = normalize(" ".join(str(event.get(key) or "") for key in (
|
|
102
|
+
"subject", "object_text", "object_canonical_name", "relation_detail"))
|
|
103
|
+
+ " " + " ".join(event.get("object_aliases") or []))
|
|
104
|
+
tags = {normalize(tag) for tag in event.get("category_tags") or []}
|
|
105
|
+
groups = (("entity", operand.get("entity_terms", []), text),
|
|
106
|
+
("detail", operand.get("relation_details", []), text))
|
|
107
|
+
failed = False
|
|
108
|
+
for label, terms, target in groups:
|
|
109
|
+
if terms and not any(" " + normalize(term) + " " in " " + target + " "
|
|
110
|
+
for term in terms):
|
|
111
|
+
rejected[label] += 1
|
|
112
|
+
failed = True
|
|
113
|
+
break
|
|
114
|
+
if failed:
|
|
115
|
+
continue
|
|
116
|
+
if operand.get("category_tags") and not any(
|
|
117
|
+
normalize(tag) in tags for tag in operand["category_tags"]):
|
|
118
|
+
rejected["category"] += 1
|
|
119
|
+
continue
|
|
120
|
+
rows.append(event)
|
|
121
|
+
return rows, {"relation_candidates": total_relation_rows,
|
|
122
|
+
"examined_events": len(relation_rows), "candidate_source": candidate_source,
|
|
123
|
+
"lexical_matches": len(rows), "rejected": rejected}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _when(event: dict) -> datetime | None:
|
|
127
|
+
# Session timestamps date a statement, not the described event.
|
|
128
|
+
if event.get("time_granularity") not in {None, "DAY", "SECOND", "MINUTE", "INSTANT"}:
|
|
129
|
+
return None
|
|
130
|
+
return iso(event.get("time_start")) or iso(event.get("date_value"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _atom(event: dict, operand: dict) -> algebra.MemoryAtom | None:
|
|
134
|
+
field = operand["value_field"]
|
|
135
|
+
unit = event.get("unit")
|
|
136
|
+
if operand.get("unit") and normalize_unit(unit) != normalize_unit(operand["unit"]):
|
|
137
|
+
return None
|
|
138
|
+
kind = operand["value_type"]
|
|
139
|
+
if field == "NUMERIC_VALUE":
|
|
140
|
+
value = event.get("numeric_value")
|
|
141
|
+
if kind != event.get("value_type") or not isinstance(value, (int, float)):
|
|
142
|
+
return None
|
|
143
|
+
elif field in {"DATE_VALUE", "SESSION_DATE"}:
|
|
144
|
+
when = _when(event) if field == "DATE_VALUE" else iso(event.get("session_timestamp"))
|
|
145
|
+
if when is None or kind != "DATE":
|
|
146
|
+
return None
|
|
147
|
+
value = when.isoformat()
|
|
148
|
+
elif field == "EVENT_COUNT":
|
|
149
|
+
value, kind, unit = event["evidence_id"], "TEXT", None
|
|
150
|
+
else:
|
|
151
|
+
value = event.get("object_text")
|
|
152
|
+
if kind not in {"TEXT", "ENTITY"} or not value:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
return algebra.MemoryAtom(kind, value, (event["evidence_id"],), _when(event), unit)
|
|
156
|
+
except ValueError:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def execute_plan(plan: dict, index: TypedMemoryIndex,
|
|
161
|
+
scope_certificates: set[str] | None = None) -> dict:
|
|
162
|
+
"""A certificate names an operand whose extraction/filter scope is complete.
|
|
163
|
+
|
|
164
|
+
Ingesting every session alone does not provide this certificate. A COUNT
|
|
165
|
+
without one returns UNKNOWN even if the current typed index has matches.
|
|
166
|
+
"""
|
|
167
|
+
certificates = scope_certificates or set()
|
|
168
|
+
operands = plan.get("operands", [])
|
|
169
|
+
names = [item["name"] for item in operands]
|
|
170
|
+
trace: dict[str, dict] = {}
|
|
171
|
+
selected: dict[str, list[dict]] = {}
|
|
172
|
+
atoms: dict[str, list[algebra.MemoryAtom]] = {}
|
|
173
|
+
|
|
174
|
+
def finish(result: algebra.AlgebraResult) -> dict:
|
|
175
|
+
return {"result": asdict(result), "operands": trace}
|
|
176
|
+
|
|
177
|
+
def fail(reason: str) -> dict:
|
|
178
|
+
return finish(algebra.unknown(reason))
|
|
179
|
+
|
|
180
|
+
if not operands or len(set(names)) != len(names):
|
|
181
|
+
return fail("INVALID_OPERAND_NAMES")
|
|
182
|
+
for operand in operands:
|
|
183
|
+
if "event_ids" in operand and not operand["event_ids"]:
|
|
184
|
+
return fail("EMPTY_EVENT_ID_BINDING")
|
|
185
|
+
if operand.get("source_scope") not in {"USER_MEMORY", "ASSISTANT_HISTORY", "BOTH"}:
|
|
186
|
+
return fail("INVALID_SOURCE_SCOPE")
|
|
187
|
+
if operand.get("selector") not in {"ALL", "UNIQUE", "LATEST", "EARLIEST"}:
|
|
188
|
+
return fail("INVALID_SELECTOR")
|
|
189
|
+
if operand.get("value_field") not in {
|
|
190
|
+
"OBJECT_TEXT", "NUMERIC_VALUE", "DATE_VALUE", "SESSION_DATE", "EVENT_COUNT"}:
|
|
191
|
+
return fail("INVALID_VALUE_FIELD")
|
|
192
|
+
unary = plan["operator"] in {"LATEST", "EARLIEST", "LOOKUP"}
|
|
193
|
+
result_name = plan.get("result_operand") or (names[0] if unary and len(names) == 1 else None)
|
|
194
|
+
if unary:
|
|
195
|
+
if result_name not in names:
|
|
196
|
+
return fail("RESULT_OPERAND_UNSPECIFIED")
|
|
197
|
+
active = {result_name} | {item["name"] for item in operands if item.get("required")}
|
|
198
|
+
while True:
|
|
199
|
+
closure = active | {item["time_reference_operand"] for item in operands
|
|
200
|
+
if item["name"] in active and item.get("time_reference_operand")}
|
|
201
|
+
if closure == active:
|
|
202
|
+
break
|
|
203
|
+
active = closure
|
|
204
|
+
operands = [item for item in operands if item["name"] in active]
|
|
205
|
+
names = [item["name"] for item in operands]
|
|
206
|
+
pending = list(operands)
|
|
207
|
+
while pending:
|
|
208
|
+
progressed = False
|
|
209
|
+
for operand in list(pending):
|
|
210
|
+
name = operand["name"]
|
|
211
|
+
reference = operand.get("time_reference_operand")
|
|
212
|
+
if reference and reference not in names:
|
|
213
|
+
return fail(name + ":UNKNOWN_TIME_REFERENCE")
|
|
214
|
+
if reference and reference not in selected:
|
|
215
|
+
continue
|
|
216
|
+
events, audit = index.candidates(operand)
|
|
217
|
+
trace[name] = audit
|
|
218
|
+
time_relation = operand.get("time_relation", "ANY")
|
|
219
|
+
boundary = None
|
|
220
|
+
if time_relation != "ANY":
|
|
221
|
+
reference_rows = selected.get(reference, [])
|
|
222
|
+
if len(reference_rows) != 1 or _when(reference_rows[0]) is None:
|
|
223
|
+
return fail(name + ":TIME_REFERENCE_MISSING_OR_NON_UNIQUE")
|
|
224
|
+
boundary = _when(reference_rows[0])
|
|
225
|
+
start = iso(operand.get("time_start"))
|
|
226
|
+
end = iso(operand.get("time_end_exclusive"))
|
|
227
|
+
if ((operand.get("time_start") and start is None)
|
|
228
|
+
or (operand.get("time_end_exclusive") and end is None)):
|
|
229
|
+
return fail(name + ":INVALID_TIME_BOUND")
|
|
230
|
+
if start and end and start >= end:
|
|
231
|
+
return fail(name + ":INVALID_TIME_RANGE")
|
|
232
|
+
if start or end or boundary:
|
|
233
|
+
if any(_when(event) is None for event in events):
|
|
234
|
+
return fail(name + ":MATCHING_EVENT_TIME_UNRESOLVED")
|
|
235
|
+
events = [event for event in events if
|
|
236
|
+
(start is None or _when(event) >= start)
|
|
237
|
+
and (end is None or _when(event) < end)]
|
|
238
|
+
if boundary:
|
|
239
|
+
predicates = {"BEFORE": lambda value: value < boundary,
|
|
240
|
+
"AFTER": lambda value: value > boundary,
|
|
241
|
+
"ON_OR_BEFORE": lambda value: value <= boundary,
|
|
242
|
+
"ON_OR_AFTER": lambda value: value >= boundary}
|
|
243
|
+
if time_relation not in predicates:
|
|
244
|
+
return fail(name + ":UNSUPPORTED_TIME_RELATION")
|
|
245
|
+
events = [event for event in events if predicates[time_relation](_when(event))]
|
|
246
|
+
selector = operand.get("selector", "ALL")
|
|
247
|
+
if events and selector in {"LATEST", "EARLIEST"}:
|
|
248
|
+
if any(_when(event) is None for event in events):
|
|
249
|
+
return fail(name + ":SELECTION_TIME_UNRESOLVED")
|
|
250
|
+
when = (max if selector == "LATEST" else min)(_when(event) for event in events)
|
|
251
|
+
events = [event for event in events if _when(event) == when]
|
|
252
|
+
if len(events) != 1:
|
|
253
|
+
return fail(name + ":SELECTION_TIE")
|
|
254
|
+
if selector == "UNIQUE" and len(events) != 1:
|
|
255
|
+
return fail(name + ":MISSING_OR_NON_UNIQUE")
|
|
256
|
+
selected[name] = events
|
|
257
|
+
atoms[name] = [_atom(event, operand) for event in events]
|
|
258
|
+
audit["selected_events"] = len(events)
|
|
259
|
+
audit["value_failures"] = sum(atom is None for atom in atoms[name])
|
|
260
|
+
if audit["value_failures"]:
|
|
261
|
+
return fail(name + ":VALUE_OR_UNIT_UNRESOLVED")
|
|
262
|
+
pending.remove(operand)
|
|
263
|
+
progressed = True
|
|
264
|
+
if not progressed:
|
|
265
|
+
return fail("CYCLIC_TIME_REFERENCE")
|
|
266
|
+
operator = plan["operator"]
|
|
267
|
+
if operator == "COUNT":
|
|
268
|
+
name = plan.get("aggregate_operand")
|
|
269
|
+
if name not in names:
|
|
270
|
+
return fail("COUNT_OPERAND_UNSPECIFIED")
|
|
271
|
+
if name not in certificates:
|
|
272
|
+
return fail("COUNT_SCOPE_NOT_CERTIFIED")
|
|
273
|
+
operand = operands[names.index(name)]
|
|
274
|
+
key_field = {"CANONICAL_ENTITY_ID": "canonical_entity_id",
|
|
275
|
+
"OCCURRENCE_ID": "occurrence_id", "OBJECT_TEXT": "object_text",
|
|
276
|
+
"EVENT_ID": "evidence_id"}.get(operand.get("distinct_by"))
|
|
277
|
+
if key_field is None:
|
|
278
|
+
return fail("COUNT_DISTINCT_KEY_UNSPECIFIED")
|
|
279
|
+
events = selected[name]
|
|
280
|
+
if any(not event.get(key_field) for event in events):
|
|
281
|
+
return fail("COUNT_DISTINCT_KEY_MISSING")
|
|
282
|
+
if not events and plan.get("empty_result") != "ZERO_IF_COMPLETE":
|
|
283
|
+
return fail("EMPTY_RESULT_UNKNOWN")
|
|
284
|
+
unique = {}
|
|
285
|
+
for event in events:
|
|
286
|
+
key = normalize(event[key_field]) if key_field == "object_text" else event[key_field]
|
|
287
|
+
unique.setdefault(key, event)
|
|
288
|
+
return finish(algebra.AlgebraResult("ANSWER", "NUMBER", len(unique), None,
|
|
289
|
+
tuple(event["evidence_id"] for event in unique.values()), "CERTIFIED_SCOPE"))
|
|
290
|
+
if plan.get("index_scope_required") and any(name not in certificates for name in names):
|
|
291
|
+
return fail("INDEX_SCOPE_NOT_CERTIFIED")
|
|
292
|
+
if operator == "SUM" and any(operand["selector"] == "ALL"
|
|
293
|
+
and operand["name"] not in certificates for operand in operands):
|
|
294
|
+
return fail("SUM_SCOPE_NOT_CERTIFIED")
|
|
295
|
+
if any(not atoms[name] for name in names):
|
|
296
|
+
return fail("MISSING_OPERATOR_VALUE")
|
|
297
|
+
if operator in {"SUBTRACT", "DATE_DIFF", "COMPARE"}:
|
|
298
|
+
if len(names) != 2 or any(len(atoms[name]) != 1 for name in names):
|
|
299
|
+
return fail("BINARY_VALUES_MISSING_OR_NON_UNIQUE")
|
|
300
|
+
left, right = (atoms[name][0] for name in names)
|
|
301
|
+
if operator == "SUBTRACT":
|
|
302
|
+
return finish(algebra.subtract(left, right))
|
|
303
|
+
if operator == "DATE_DIFF":
|
|
304
|
+
return finish(algebra.date_diff(left, right, str(plan.get("result_unit") or "days").lower()))
|
|
305
|
+
return finish(algebra.compare(left, right))
|
|
306
|
+
output_names = [result_name] if unary else names
|
|
307
|
+
values = list({atom.evidence_event_ids: atom for name in output_names for atom in atoms[name]}.values())
|
|
308
|
+
operations = {"SUM": algebra.sum_values, "LATEST": algebra.latest,
|
|
309
|
+
"EARLIEST": algebra.earliest, "LOOKUP": algebra.lookup}
|
|
310
|
+
if operator not in operations:
|
|
311
|
+
return fail("UNSUPPORTED_OPERATOR")
|
|
312
|
+
return finish(operations[operator](values))
|