Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc38e2ae3 |
@@ -141,7 +141,6 @@ from .entity_resolver import EntityResolver
|
||||
from .llm_wrapper import LLMConfig
|
||||
from .query_analyzer import QueryAnalyzer
|
||||
from .reflect import run_reflect_agent
|
||||
from .reflect.models import ObservationInput
|
||||
from .reflect.tools import tool_expand, tool_recall, tool_search_mental_models, tool_search_observations
|
||||
from .response_models import (
|
||||
VALID_RECALL_FACT_TYPES,
|
||||
@@ -535,7 +534,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"""
|
||||
Handler for consolidation tasks.
|
||||
|
||||
Consolidates new memories into learnings for a bank.
|
||||
Consolidates new memories into mental models for a bank.
|
||||
|
||||
Args:
|
||||
task_dict: Dict with 'bank_id'
|
||||
@@ -1610,7 +1609,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
)
|
||||
|
||||
# Filter out 'opinion' - opinions are no longer returned from recall
|
||||
# (learnings are now stored as mental models instead)
|
||||
fact_type = [ft for ft in fact_type if ft != "opinion"]
|
||||
if not fact_type:
|
||||
# All requested types were opinions - return empty result
|
||||
@@ -3519,7 +3517,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
ReflectResult containing:
|
||||
- text: Plain text answer
|
||||
- based_on: Empty dict (agent retrieves facts dynamically)
|
||||
- new_opinions: Empty list (learnings stored as mental models)
|
||||
- new_opinions: Empty list
|
||||
- structured_output: None (not yet supported for agentic reflect)
|
||||
"""
|
||||
# Use cached LLM config
|
||||
@@ -4362,314 +4360,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
)
|
||||
return updated_count
|
||||
|
||||
# =========================================================================
|
||||
# LEARNINGS CRUD
|
||||
# =========================================================================
|
||||
|
||||
async def list_learnings(
|
||||
self,
|
||||
bank_id: str,
|
||||
*,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: str = "any",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List learnings for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
tags: Optional tags to filter by
|
||||
tags_match: How to match tags - 'any', 'all', or 'exact'
|
||||
limit: Maximum number of results
|
||||
offset: Offset for pagination
|
||||
request_context: Request context for authentication
|
||||
|
||||
Returns:
|
||||
List of learning dicts
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Build tag filter
|
||||
tag_filter = ""
|
||||
params: list[Any] = [bank_id, limit, offset]
|
||||
if tags:
|
||||
if tags_match == "all":
|
||||
tag_filter = " AND tags @> $4::varchar[]"
|
||||
elif tags_match == "exact":
|
||||
tag_filter = " AND tags = $4::varchar[]"
|
||||
else: # any
|
||||
tag_filter = " AND tags && $4::varchar[]"
|
||||
params.append(tags)
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, bank_id, text, proof_count, history, mission_context,
|
||||
pre_mission_change, tags, created_at, updated_at
|
||||
FROM {fq_table("learnings")}
|
||||
WHERE bank_id = $1 {tag_filter}
|
||||
ORDER BY proof_count DESC, updated_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
|
||||
return [self._row_to_learning(row) for row in rows]
|
||||
|
||||
async def get_learning(
|
||||
self,
|
||||
bank_id: str,
|
||||
learning_id: str,
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get a single learning by ID.
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
learning_id: Learning UUID
|
||||
request_context: Request context for authentication
|
||||
|
||||
Returns:
|
||||
Learning dict or None if not found
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT id, bank_id, text, proof_count, history, mission_context,
|
||||
pre_mission_change, tags, created_at, updated_at
|
||||
FROM {fq_table("learnings")}
|
||||
WHERE bank_id = $1 AND id = $2
|
||||
""",
|
||||
bank_id,
|
||||
learning_id,
|
||||
)
|
||||
|
||||
return self._row_to_learning(row) if row else None
|
||||
|
||||
async def create_learning(
|
||||
self,
|
||||
bank_id: str,
|
||||
text: str,
|
||||
*,
|
||||
proof_count: int = 1,
|
||||
tags: list[str] | None = None,
|
||||
mission_context: str | None = None,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new learning.
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
text: The learning text
|
||||
proof_count: Initial proof count (default 1)
|
||||
tags: Optional tags for scoped visibility
|
||||
mission_context: Hash of mission when created
|
||||
request_context: Request context for authentication
|
||||
|
||||
Returns:
|
||||
The created learning dict
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
|
||||
# Generate embedding for the learning text
|
||||
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text])
|
||||
# Convert embedding to string for asyncpg vector type
|
||||
embedding_str = str(embedding[0]) if embedding else None
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO {fq_table("learnings")}
|
||||
(bank_id, text, proof_count, mission_context, embedding, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, bank_id, text, proof_count, history, mission_context,
|
||||
pre_mission_change, tags, created_at, updated_at
|
||||
""",
|
||||
bank_id,
|
||||
text,
|
||||
proof_count,
|
||||
mission_context,
|
||||
embedding_str,
|
||||
tags or [],
|
||||
)
|
||||
|
||||
logger.info(f"[LEARNINGS] Created learning for bank {bank_id}: {text[:50]}...")
|
||||
return self._row_to_learning(row)
|
||||
|
||||
async def update_learning(
|
||||
self,
|
||||
bank_id: str,
|
||||
learning_id: str,
|
||||
*,
|
||||
text: str | None = None,
|
||||
increment_proof: bool = False,
|
||||
add_history: dict[str, Any] | None = None,
|
||||
mark_pre_mission_change: bool = False,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a learning.
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
learning_id: Learning UUID
|
||||
text: New text (if changing)
|
||||
increment_proof: Whether to increment proof_count
|
||||
add_history: History entry to append (for contradictions)
|
||||
mark_pre_mission_change: Whether to mark as pre-mission-change
|
||||
request_context: Request context for authentication
|
||||
|
||||
Returns:
|
||||
Updated learning dict or None if not found
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Build dynamic update
|
||||
updates = ["updated_at = NOW()"]
|
||||
params: list[Any] = [bank_id, learning_id]
|
||||
param_idx = 3
|
||||
|
||||
if text is not None:
|
||||
updates.append(f"text = ${param_idx}")
|
||||
params.append(text)
|
||||
param_idx += 1
|
||||
# Also update embedding (convert to string for asyncpg vector type)
|
||||
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text])
|
||||
if embedding:
|
||||
updates.append(f"embedding = ${param_idx}")
|
||||
params.append(str(embedding[0]))
|
||||
param_idx += 1
|
||||
|
||||
if increment_proof:
|
||||
updates.append("proof_count = proof_count + 1")
|
||||
|
||||
if add_history:
|
||||
import json
|
||||
|
||||
updates.append(f"history = history || ${param_idx}::jsonb")
|
||||
params.append(json.dumps([add_history]))
|
||||
param_idx += 1
|
||||
|
||||
if mark_pre_mission_change:
|
||||
updates.append("pre_mission_change = TRUE")
|
||||
|
||||
query = f"""
|
||||
UPDATE {fq_table("learnings")}
|
||||
SET {", ".join(updates)}
|
||||
WHERE bank_id = $1 AND id = $2
|
||||
RETURNING id, bank_id, text, proof_count, history, mission_context,
|
||||
pre_mission_change, tags, created_at, updated_at
|
||||
"""
|
||||
|
||||
row = await conn.fetchrow(query, *params)
|
||||
|
||||
return self._row_to_learning(row) if row else None
|
||||
|
||||
async def delete_learning(
|
||||
self,
|
||||
bank_id: str,
|
||||
learning_id: str,
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
) -> bool:
|
||||
"""Delete a learning.
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
learning_id: Learning UUID
|
||||
request_context: Request context for authentication
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
result = await conn.execute(
|
||||
f"DELETE FROM {fq_table('learnings')} WHERE bank_id = $1 AND id = $2",
|
||||
bank_id,
|
||||
learning_id,
|
||||
)
|
||||
|
||||
return result == "DELETE 1"
|
||||
|
||||
def _row_to_learning(self, row) -> dict[str, Any]:
|
||||
"""Convert a database row to a learning dict."""
|
||||
import json
|
||||
|
||||
# Parse history - asyncpg may return JSONB as string in some cases
|
||||
history = row["history"]
|
||||
if isinstance(history, str):
|
||||
history = json.loads(history)
|
||||
elif history is None:
|
||||
history = []
|
||||
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"bank_id": row["bank_id"],
|
||||
"text": row["text"],
|
||||
"proof_count": row["proof_count"],
|
||||
"history": history,
|
||||
"mission_context": row["mission_context"],
|
||||
"pre_mission_change": row["pre_mission_change"],
|
||||
"tags": row["tags"] or [],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
}
|
||||
|
||||
async def mark_learnings_pre_mission_change(
|
||||
self,
|
||||
bank_id: str,
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
) -> int:
|
||||
"""Mark all learnings as pre-mission-change when mission changes.
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
request_context: Request context for authentication
|
||||
|
||||
Returns:
|
||||
Number of learnings marked
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
result = await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("learnings")}
|
||||
SET pre_mission_change = TRUE, updated_at = NOW()
|
||||
WHERE bank_id = $1 AND pre_mission_change = FALSE
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
# Also update bank's mission_changed_at
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("banks")}
|
||||
SET mission_changed_at = NOW()
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
count = int(result.split()[-1]) if result and "UPDATE" in result else 0
|
||||
if count > 0:
|
||||
logger.info(f"[LEARNINGS] Marked {count} learnings as pre-mission-change for bank {bank_id}")
|
||||
return count
|
||||
|
||||
# =========================================================================
|
||||
# MENTAL MODELS (CONSOLIDATED) - Read-only access to auto-consolidated mental models
|
||||
# =========================================================================
|
||||
|
||||
@@ -4,17 +4,15 @@ Reflect agent module for agentic reflection with tools.
|
||||
The reflect agent uses an iterative loop with tools to:
|
||||
1. Lookup mental models (existing knowledge)
|
||||
2. Recall facts (semantic + temporal search)
|
||||
3. Learn new insights (create/update observations)
|
||||
4. Expand memories (get chunk/document context)
|
||||
3. Expand memories (get chunk/document context)
|
||||
"""
|
||||
|
||||
from .agent import ReflectAgentResult, run_reflect_agent
|
||||
from .models import ObservationInput, ReflectAction, ReflectActionBatch
|
||||
from .models import ReflectAction, ReflectActionBatch
|
||||
|
||||
__all__ = [
|
||||
"run_reflect_agent",
|
||||
"ReflectAgentResult",
|
||||
"ReflectAction",
|
||||
"ReflectActionBatch",
|
||||
"ObservationInput",
|
||||
]
|
||||
|
||||
@@ -15,41 +15,18 @@ class ObservationSection(BaseModel):
|
||||
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
|
||||
|
||||
|
||||
class ObservationInput(BaseModel):
|
||||
"""Input for the learn tool to create an observation placeholder.
|
||||
|
||||
The agent only specifies name and description - the actual content/sections
|
||||
are generated during refresh, similar to pinned models.
|
||||
"""
|
||||
|
||||
name: str = Field(description="Human-readable name for the observation")
|
||||
description: str = Field(description="What to track - used as prompt for content generation during refresh")
|
||||
entity_id: str | None = Field(default=None, description="Optional link to existing entity ID")
|
||||
|
||||
|
||||
class AnswerSection(BaseModel):
|
||||
"""A section of the answer with its supporting evidence (DEPRECATED)."""
|
||||
|
||||
title: str = Field(description="Section header/title")
|
||||
text: str = Field(description="Section content")
|
||||
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
|
||||
model_ids: list[str] = Field(default_factory=list, description="Mental model IDs supporting this section")
|
||||
|
||||
|
||||
class ReflectAction(BaseModel):
|
||||
"""Single action the reflect agent can take."""
|
||||
|
||||
tool: Literal["list_observations", "get_observation", "recall", "learn", "expand", "done"] = Field(
|
||||
description="Tool to invoke: list_observations, get_observation, recall, learn, expand, or done"
|
||||
tool: Literal["list_observations", "get_observation", "recall", "expand", "done"] = Field(
|
||||
description="Tool to invoke: list_observations, get_observation, recall, expand, or done"
|
||||
)
|
||||
# Tool-specific parameters
|
||||
observation_id: str | None = Field(default=None, description="Observation ID for get_observation")
|
||||
query: str | None = Field(default=None, description="Search query for recall")
|
||||
max_tokens: int | None = Field(default=None, description="Max tokens for recall results (default 2048)")
|
||||
observation: ObservationInput | None = Field(default=None, description="Observation to create/update for learn")
|
||||
memory_ids: list[str] | None = Field(default=None, description="Memory unit IDs for expand (batched)")
|
||||
depth: Literal["chunk", "document"] | None = Field(default=None, description="Expansion depth for expand")
|
||||
sections: list[AnswerSection] | None = Field(default=None, description="DEPRECATED: Use answer field instead")
|
||||
observation_sections: list[ObservationSection] | None = Field(
|
||||
default=None, description="Observation sections for done action (when output_mode=observations)"
|
||||
)
|
||||
@@ -73,7 +50,7 @@ class ReflectActionBatch(BaseModel):
|
||||
class ToolCall(BaseModel):
|
||||
"""A single tool call made during reflect."""
|
||||
|
||||
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
|
||||
tool: str = Field(description="Tool name: lookup, recall, expand")
|
||||
input: dict = Field(description="Tool input parameters")
|
||||
output: dict = Field(description="Tool output/result")
|
||||
duration_ms: int = Field(description="Execution time in milliseconds")
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
"""
|
||||
Scoring functions for memory search and retrieval.
|
||||
|
||||
Includes recency weighting, frequency weighting, temporal proximity,
|
||||
and similarity calculations used in memory activation and ranking.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
||||
"""
|
||||
Calculate cosine similarity between two vectors.
|
||||
|
||||
Args:
|
||||
vec1: First vector
|
||||
vec2: Second vector
|
||||
|
||||
Returns:
|
||||
Similarity score between 0 and 1
|
||||
"""
|
||||
if len(vec1) != len(vec2):
|
||||
raise ValueError("Vectors must have same dimension")
|
||||
|
||||
dot_product = sum(a * b for a, b in zip(vec1, vec2))
|
||||
magnitude1 = sum(a * a for a in vec1) ** 0.5
|
||||
magnitude2 = sum(b * b for b in vec2) ** 0.5
|
||||
|
||||
if magnitude1 == 0 or magnitude2 == 0:
|
||||
return 0.0
|
||||
|
||||
return dot_product / (magnitude1 * magnitude2)
|
||||
|
||||
|
||||
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
|
||||
"""
|
||||
Calculate recency weight using logarithmic decay.
|
||||
|
||||
This provides much better differentiation over long time periods compared to
|
||||
exponential decay. Uses a log-based decay where the half-life parameter controls
|
||||
when memories reach 50% weight.
|
||||
|
||||
Examples:
|
||||
- Today (0 days): 1.0
|
||||
- 1 year (365 days): ~0.5 (with default half_life=365)
|
||||
- 2 years (730 days): ~0.33
|
||||
- 5 years (1825 days): ~0.17
|
||||
- 10 years (3650 days): ~0.09
|
||||
|
||||
This ensures that 2-year-old and 5-year-old memories have meaningfully
|
||||
different weights, unlike exponential decay which makes them both ~0.
|
||||
|
||||
Args:
|
||||
days_since: Number of days since the memory was created
|
||||
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
|
||||
|
||||
Returns:
|
||||
Weight between 0 and 1
|
||||
"""
|
||||
import math
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
|
||||
# This decays much slower than exponential, giving better long-term differentiation
|
||||
normalized_age = days_since / half_life_days
|
||||
return 1.0 / (1.0 + math.log1p(normalized_age))
|
||||
|
||||
|
||||
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
|
||||
"""
|
||||
Calculate a single temporal anchor point from a temporal range.
|
||||
|
||||
Used for spreading activation - we need a single representative date
|
||||
to calculate temporal proximity between facts. This simplifies the
|
||||
range-to-range distance problem.
|
||||
|
||||
Strategy: Use midpoint of the range for balanced representation.
|
||||
|
||||
Args:
|
||||
occurred_start: Start of temporal range
|
||||
occurred_end: End of temporal range
|
||||
|
||||
Returns:
|
||||
Single datetime representing the temporal anchor (midpoint)
|
||||
|
||||
Examples:
|
||||
- Point event (July 14): start=July 14, end=July 14 → anchor=July 14
|
||||
- Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14
|
||||
- Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1
|
||||
"""
|
||||
# Calculate midpoint
|
||||
time_delta = occurred_end - occurred_start
|
||||
midpoint = occurred_start + (time_delta / 2)
|
||||
return midpoint
|
||||
|
||||
|
||||
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
|
||||
"""
|
||||
Calculate temporal proximity between two temporal anchors.
|
||||
|
||||
Used for spreading activation to determine how "close" two facts are
|
||||
in time. Uses logarithmic decay so that temporal similarity doesn't
|
||||
drop off too quickly.
|
||||
|
||||
Args:
|
||||
anchor_a: Temporal anchor of first fact
|
||||
anchor_b: Temporal anchor of second fact
|
||||
half_life_days: Number of days for proximity to reach 0.5
|
||||
(default: 30 days = 1 month)
|
||||
|
||||
Returns:
|
||||
Proximity score in [0, 1] where:
|
||||
- 1.0 = same day
|
||||
- 0.5 = ~half_life days apart
|
||||
- 0.0 = very distant in time
|
||||
|
||||
Examples:
|
||||
- Same day: 1.0
|
||||
- 1 week apart (half_life=30): ~0.7
|
||||
- 1 month apart (half_life=30): ~0.5
|
||||
- 1 year apart (half_life=30): ~0.2
|
||||
"""
|
||||
import math
|
||||
|
||||
days_apart = abs((anchor_a - anchor_b).days)
|
||||
|
||||
if days_apart == 0:
|
||||
return 1.0
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
|
||||
# Similar to calculate_recency_weight but for proximity between events
|
||||
normalized_distance = days_apart / half_life_days
|
||||
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
|
||||
|
||||
return proximity
|
||||
@@ -65,129 +65,3 @@ async def extract_facts(
|
||||
return [], chunks
|
||||
|
||||
return facts, chunks
|
||||
|
||||
|
||||
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
||||
"""
|
||||
Calculate cosine similarity between two vectors.
|
||||
|
||||
Args:
|
||||
vec1: First vector
|
||||
vec2: Second vector
|
||||
|
||||
Returns:
|
||||
Similarity score between 0 and 1
|
||||
"""
|
||||
if len(vec1) != len(vec2):
|
||||
raise ValueError("Vectors must have same dimension")
|
||||
|
||||
dot_product = sum(a * b for a, b in zip(vec1, vec2))
|
||||
magnitude1 = sum(a * a for a in vec1) ** 0.5
|
||||
magnitude2 = sum(b * b for b in vec2) ** 0.5
|
||||
|
||||
if magnitude1 == 0 or magnitude2 == 0:
|
||||
return 0.0
|
||||
|
||||
return dot_product / (magnitude1 * magnitude2)
|
||||
|
||||
|
||||
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
|
||||
"""
|
||||
Calculate recency weight using logarithmic decay.
|
||||
|
||||
This provides much better differentiation over long time periods compared to
|
||||
exponential decay. Uses a log-based decay where the half-life parameter controls
|
||||
when memories reach 50% weight.
|
||||
|
||||
Examples:
|
||||
- Today (0 days): 1.0
|
||||
- 1 year (365 days): ~0.5 (with default half_life=365)
|
||||
- 2 years (730 days): ~0.33
|
||||
- 5 years (1825 days): ~0.17
|
||||
- 10 years (3650 days): ~0.09
|
||||
|
||||
This ensures that 2-year-old and 5-year-old memories have meaningfully
|
||||
different weights, unlike exponential decay which makes them both ~0.
|
||||
|
||||
Args:
|
||||
days_since: Number of days since the memory was created
|
||||
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
|
||||
|
||||
Returns:
|
||||
Weight between 0 and 1
|
||||
"""
|
||||
import math
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
|
||||
# This decays much slower than exponential, giving better long-term differentiation
|
||||
normalized_age = days_since / half_life_days
|
||||
return 1.0 / (1.0 + math.log1p(normalized_age))
|
||||
|
||||
|
||||
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
|
||||
"""
|
||||
Calculate a single temporal anchor point from a temporal range.
|
||||
|
||||
Used for spreading activation - we need a single representative date
|
||||
to calculate temporal proximity between facts. This simplifies the
|
||||
range-to-range distance problem.
|
||||
|
||||
Strategy: Use midpoint of the range for balanced representation.
|
||||
|
||||
Args:
|
||||
occurred_start: Start of temporal range
|
||||
occurred_end: End of temporal range
|
||||
|
||||
Returns:
|
||||
Single datetime representing the temporal anchor (midpoint)
|
||||
|
||||
Examples:
|
||||
- Point event (July 14): start=July 14, end=July 14 → anchor=July 14
|
||||
- Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14
|
||||
- Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1
|
||||
"""
|
||||
# Calculate midpoint
|
||||
time_delta = occurred_end - occurred_start
|
||||
midpoint = occurred_start + (time_delta / 2)
|
||||
return midpoint
|
||||
|
||||
|
||||
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
|
||||
"""
|
||||
Calculate temporal proximity between two temporal anchors.
|
||||
|
||||
Used for spreading activation to determine how "close" two facts are
|
||||
in time. Uses logarithmic decay so that temporal similarity doesn't
|
||||
drop off too quickly.
|
||||
|
||||
Args:
|
||||
anchor_a: Temporal anchor of first fact
|
||||
anchor_b: Temporal anchor of second fact
|
||||
half_life_days: Number of days for proximity to reach 0.5
|
||||
(default: 30 days = 1 month)
|
||||
|
||||
Returns:
|
||||
Proximity score in [0, 1] where:
|
||||
- 1.0 = same day
|
||||
- 0.5 = ~half_life days apart
|
||||
- 0.0 = very distant in time
|
||||
|
||||
Examples:
|
||||
- Same day: 1.0
|
||||
- 1 week apart (half_life=30): ~0.7
|
||||
- 1 month apart (half_life=30): ~0.5
|
||||
- 1 year apart (half_life=30): ~0.2
|
||||
"""
|
||||
import math
|
||||
|
||||
days_apart = abs((anchor_a - anchor_b).days)
|
||||
|
||||
if days_apart == 0:
|
||||
return 1.0
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
|
||||
# Similar to calculate_recency_weight but for proximity between events
|
||||
normalized_distance = days_apart / half_life_days
|
||||
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
|
||||
|
||||
return proximity
|
||||
|
||||
Reference in New Issue
Block a user