Ctrl K
ComunidadGuías…

cortexm/experimental/coherence.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Temporal-coherence reranking signal (μ=0).

Borrowed from RuVector's temporal-coherence gating ("prefer memories
supported by related observations"), stripped to a deterministic
heuristic:

  * a fact's coherence score is the number of DISTINCT other facts,
    in the same candidate pool, whose valid_from lies within
    ``window_days`` of it AND which share an entity (subject or
    value) with it;
  * the raw count is normalized to [0, 1] (capped at 5 corroborating
    facts) and used as a small ADDITIVE boost by the reader.

Intuition: an event that really happened in a given week usually
left several mutually-connected facts around that week (the
conversation about it, related decisions, follow-ups). A fact that
only similarity-matched the query but has no temporal neighborhood
is more likely to be the WRONG session's look-alike. This targets
the temporal_reasoning failures on multi-week relative references
("two weeks after she started the new job") where pure similarity
pulls the wrong time slice.

μ=0: date parsing, set intersection, counting. No learned weights.
"""
from __future__ import annotations

from datetime import datetime, timedelta
from typing import Any

try:  # Python 3.11+
    from datetime import UTC
except ImportError:  # pragma: no cover
    UTC = None


def _parse_valid_from(value: str | None) -> datetime | None:
    """Parse a fact's valid_from into a datetime, tolerantly.

    LongMemEval-style stores carry ``YYYY-MM-DD`` (sometimes with a
    time part, sometimes Z-suffixed). Returns None for anything
    unparseable — those facts simply get no coherence boost.
    """
    if not value:
        return None
    s = str(value).strip()
    if not s:
        return None
    # date-only fast path
    try:
        return datetime.strptime(s[:10], "%Y-%m-%d")
    except ValueError:
        pass
    for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S",
                "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M"):
        try:
            return datetime.strptime(s[:26], fmt)
        except ValueError:
            continue
    return None


def coherence_scores(facts: list[Any], window_days: int = 30,
                     cap: int = 5) -> dict[str, float]:
    """Deterministic temporal-coherence score per fact.

    Returns {fact_id: score in [0, 1]} — 0 for facts without a
    parseable valid_from. O(n log n): sort by timestamp, then a
    two-pointer window pass; shared-entity checking uses the
    entity-token sets computed once per fact.
    """
    if not facts:
        return {}
    window = timedelta(days=window_days if window_days > 0 else 30)

    # ---- parse + entity tokens (one pass) ----
    entries: list[tuple[datetime, Any, frozenset[str]]] = []
    entity_tokens: dict[str, frozenset[str]] = {}
    for f in facts:
        dt = _parse_valid_from(getattr(f, "valid_from", None))
        if dt is None:
            continue
        subj = (getattr(f, "subject", "") or "").strip().lower()
        val_toks = frozenset(
            p.strip("'\"!?;:()[]").lower()
            for p in (getattr(f, "value", "") or "").replace(",", " ") \
                .replace(".", " ").split()
            if len(p) >= 3 and p.isalnum())
        ents = frozenset(e for e in ({subj} | val_toks) if e)
        entity_tokens[f.id] = ents
        entries.append((dt, f, ents))

    scores: dict[str, float] = {f.id: 0.0 for f in facts}
    if len(entries) < 2:
        return scores

    # ---- sort by timestamp (deterministic tie-break on id) ----
    entries.sort(key=lambda e: (e[0], e[1].id))

    # ---- two-pointer window pass ----
    # For each fact i, facts j in (t_i - window, t_i + window) with a
    # shared entity contribute one corroboration each (distinct j).
    n = len(entries)
    counts: dict[str, int] = {}
    for i, (dt_i, f_i, ents_i) in enumerate(entries):
        c = counts.get(f_i.id, 0)
        # forward scan
        j = i + 1
        while j < n and entries[j][0] <= dt_i + window:
            if ents_i & entries[j][2]:
                c += 1
            j += 1
        # backward scan
        j = i - 1
        while j >= 0 and entries[j][0] >= dt_i - window:
            if ents_i & entries[j][2]:
                c += 1
            j -= 1
        counts[f_i.id] = c

    for f in facts:
        c = counts.get(f.id, 0)
        scores[f.id] = min(1.0, c / cap) if cap > 0 else 0.0
    return scores