Coverage for astrocyte/pipeline/trend.py: 98%
40 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 05:24 +0000
1"""Computed trend for observations based on evidence timestamps (M21).
3A :class:`Trend` value indicates how an observation's supporting
4evidence is distributed over time:
6- :attr:`Trend.STABLE` — evidence spread across the analysis window,
7 continues to present.
8- :attr:`Trend.STRENGTHENING` — denser evidence recently than
9 historically.
10- :attr:`Trend.WEAKENING` — evidence mostly old, sparse recently.
11- :attr:`Trend.NEW` — all evidence within the recent window.
12- :attr:`Trend.STALE` — no evidence in the recent window (observation
13 may no longer apply).
15Trend is computed *algorithmically* from evidence timestamps, not
16generated by the LLM, so it's cheap (microseconds per observation) and
17deterministic — same evidence list always produces the same trend.
19Thresholds (``recent_days=30``, ``old_days=90``) mirror Hindsight's
20defaults; they're hard-coded in M21 (no config knob) and can be
21exposed as :class:`~astrocyte.config.AstrocyteConfig` fields in a
22future release if users ask. Tuning is unlikely to matter because
23trends are *comparative* — STALE vs NEW for ranking, not absolute
24day-counts.
26Ported from Hindsight ``hindsight_api/engine/reflect/observations.py``
27under the project's MIT licence.
29See :class:`astrocyte.pipeline.observation.ObservationConsolidator` for
30how trend is surfaced on consolidation results, and
31:mod:`astrocyte.pipeline.orchestrator` for how trend is attached to
32observation hits at recall time.
33"""
35from __future__ import annotations
37from datetime import datetime, timedelta, timezone
38from enum import Enum
41class Trend(str, Enum):
42 """Computed trend for an observation based on evidence timestamps."""
44 STABLE = "stable"
45 STRENGTHENING = "strengthening"
46 WEAKENING = "weakening"
47 NEW = "new"
48 STALE = "stale"
51def _normalize_ts(ts: datetime) -> datetime:
52 """Force a timestamp into UTC. Naive datetimes are assumed UTC."""
53 if ts.tzinfo is None:
54 return ts.replace(tzinfo=timezone.utc)
55 return ts
58def compute_trend(
59 evidence_timestamps: list[datetime],
60 *,
61 now: datetime | None = None,
62 recent_days: int = 30,
63 old_days: int = 90,
64) -> Trend:
65 """Compute the trend for an observation from its evidence timestamps.
67 Args:
68 evidence_timestamps: Timestamps of the memories that support
69 this observation. Empty list returns :attr:`Trend.STALE`.
70 Order doesn't matter.
71 now: Reference time for the analysis window. Defaults to
72 ``datetime.now(timezone.utc)``. Naive datetimes are
73 assumed to be UTC.
74 recent_days: Days back from ``now`` that count as "recent".
75 Default 30.
76 old_days: Days back from ``now`` beyond which evidence counts
77 as "old". Default 90. (Evidence between ``recent_days``
78 and ``old_days`` counts as "middle".)
80 Returns:
81 A :class:`Trend` enum value.
83 Algorithm:
84 1. If no evidence at all → ``STALE``.
85 2. If no evidence in the recent window → ``STALE``.
86 3. If all evidence is in the recent window → ``NEW``.
87 4. Compare density (evidence per day) in the recent vs. older
88 periods:
89 - ratio > 1.5 → ``STRENGTHENING``
90 - ratio < 0.5 → ``WEAKENING``
91 - otherwise → ``STABLE``
92 """
93 if now is None:
94 now = datetime.now(timezone.utc)
95 now = _normalize_ts(now)
97 if not evidence_timestamps:
98 return Trend.STALE
100 recent_cutoff = now - timedelta(days=recent_days)
101 old_cutoff = now - timedelta(days=old_days)
103 normalized = [_normalize_ts(ts) for ts in evidence_timestamps]
104 recent = [ts for ts in normalized if ts > recent_cutoff]
105 old = [ts for ts in normalized if ts < old_cutoff]
106 middle = [ts for ts in normalized if old_cutoff <= ts <= recent_cutoff]
108 # No recent evidence at all → stale.
109 if not recent:
110 return Trend.STALE
112 # All evidence is recent → new.
113 if not old and not middle:
114 return Trend.NEW
116 # Compare density (evidence per day) in the recent window vs the older period.
117 recent_density = len(recent) / recent_days if recent_days > 0 else 0.0
118 older_period_days = old_days - recent_days
119 older_density = (len(old) + len(middle)) / older_period_days if older_period_days > 0 else 0.0
121 # If older density is zero but we got past the "no old/middle" check,
122 # we have strictly recent + some boundary evidence — treat as NEW.
123 if older_density == 0:
124 return Trend.NEW
126 ratio = recent_density / older_density
127 if ratio > 1.5:
128 return Trend.STRENGTHENING
129 if ratio < 0.5:
130 return Trend.WEAKENING
131 return Trend.STABLE