Skip to content

feat(evaluation): judge retrieved learnings against their original rows - #338

Merged
yyiilluu merged 1 commit into
mainfrom
feat/rle-historical-resolution
Jul 12, 2026
Merged

feat(evaluation): judge retrieved learnings against their original rows#338
yyiilluu merged 1 commit into
mainfrom
feat/rle-historical-resolution

Conversation

@yyiilluu

@yyiilluu yyiilluu commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Retrieved-learning evaluation (RLE) judged only learnings that were still retrieval-eligible at judge time — status IS NULL, unexpired, and (for agent playbooks) approved. That asks the wrong question.

A learning that was injected into a response and then archived, expired, or un-approved is still the exact learning the agent saw. Filtering it out silently discards a verdict about work that actually happened. It also created a race: a lifecycle change landing mid-run — aggregation or consolidation completing while the LLM judges are in flight — could make an in-flight verdict vanish at commit time.

This reframes the invariant from "is it still retrievable?" to "does the original row still exist?".

Changes

include_inactive on the three bulk by-id getters (get_profiles_by_ids, get_user_playbooks_by_ids, get_agent_playbooks_by_ids). Returns every matching owned row regardless of lifecycle status, expiry, or approval.

It is a strict superset of the existing include_tombstones flag on the singular by-id getters, which only unhides MERGED/SUPERSEDED for lineage walks. Both flags now cross-reference each other in the storage_base docstrings so the next caller can tell them apart. user_id scoping still applies, and the default preserves existing retrieval behavior.

_rle_eligible_refs_rle_resolvable_refs on the commit path. It still locks (FOR SHARE) and rechecks the source rows in-transaction, but the lifecycle predicates are gone — it now verifies row existence only. The attached AND resolvable commit contract is otherwise unchanged.

Resolution never follows a lineage pointer to a successor. The judge sees the original content, never the row that replaced it.

A contradictory-argument guard. Passing include_inactive=True and an explicit status_filter is a caller bug: the filter would be silently dropped and the caller would get back rows it explicitly asked to exclude. validate_include_inactive rejects the combination.

It raises StorageError rather than ValueError for a specific reason: both handle_exceptions decorators re-raise StorageError untouched, but wrap any other exception type — and on the Supabase path also report it to Sentry. Raising ValueError made a pure caller bug surface as an opaque StorageError on one backend and a ValueError on another, with Sentry noise attached. It now fails loud and identically on every backend.

Notes

Resolution is best-effort by design, not a durability guarantee. Row retention evicts tombstoned rows first, so a genuinely-served learning can age out and then resolve to nothing. That is a silent skip — an old session simply judges fewer learnings. Documented on _resolve_candidates.

Test Plan

  • Contract tests for include_inactive on all three getters, covering that an archived row is returned, that user_id remains the only surviving tenant wall under the flag (it strips every other predicate, so this is now the one thing standing between tenants on that path), and that the contradictory filter combination is rejected.
  • Evaluator tests that an archived, expired, or never-approved attached learning is still judged.
  • A superseded learning is judged on its original content — asserted negatively, i.e. that the successor's content never reaches the judge payload.
  • 109 passed across the evaluator and storage-contract suites (up from 103). Ruff and Pyright clean.

Summary by CodeRabbit

  • New Features

    • Historical learning evaluations now continue to resolve attached archived, expired, or unapproved records when their original data remains available.
    • Evaluation results now retain verdicts for sources that become inactive during judging.
    • Historical lookups can include inactive records while preserving ownership and identity boundaries.
  • Bug Fixes

    • Superseded records now resolve to their original content rather than successor data.
  • Validation

    • Conflicting lifecycle filters are rejected with a clear error when inactive-record lookup is enabled.

Retrieved-learning evaluation (RLE) judged only learnings that were still
*retrieval-eligible* at judge time — status NULL, unexpired, and (for agent
playbooks) approved. That is the wrong question. A learning that was injected
into a response and then archived, expired, or un-approved was still the exact
learning the agent saw, so dropping it silently discards a verdict about work
that actually happened. Worse, a lifecycle change landing mid-run (aggregation
or consolidation completing while the LLM judges are in flight) could make an
in-flight verdict vanish at commit.

Reframe the invariant from "still retrievable" to "the original row still
exists":

- Add `include_inactive` to the three bulk by-id getters. It returns every
  matching owned row regardless of lifecycle status, expiry, or approval. It is
  a strict superset of the existing `include_tombstones` flag on the singular
  by-id getters (which only unhides MERGED/SUPERSEDED for lineage walks);
  `user_id` scoping still applies, and the default preserves retrieval behavior.
- Rename the RLE commit-path check `_rle_eligible_refs` -> `_rle_resolvable_refs`
  and drop its lifecycle predicates: it now locks and rechecks row *existence*
  only. The `attached AND resolvable` commit contract is unchanged.
- Resolution never follows a lineage pointer to a successor — the judge sees the
  original content, never the row that replaced it.

Combining `include_inactive` with an explicit `status_filter` is contradictory:
the filter would be silently dropped and the caller would get back rows it asked
to exclude. `validate_include_inactive` rejects that combination, raising
StorageError specifically because both `handle_exceptions` decorators re-raise
StorageError untouched while wrapping any other exception type (and, on the
Supabase path, reporting it to Sentry) — a caller bug should fail loud and
identically on every backend, not become error-report noise.

Resolution is best-effort by design: row retention evicts tombstones first, so a
genuinely-served learning can age out and then resolve to nothing. An old session
simply judges fewer learnings.

Tests: contract coverage for `include_inactive` on all three getters, including
that `user_id` remains the only surviving tenant wall under the flag and that the
contradictory filter combination is rejected; evaluator tests that an archived,
expired, or never-approved attached learning is still judged, and that a
superseded learning is judged on its original content rather than its successor's.
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds historical-resolution modes to bulk storage getters, changes retrieved-learning evaluation from lifecycle eligibility to underlying-row existence, and updates candidate resolution and contract tests for archived, expired, unapproved, superseded, and missing learnings.

Changes

Historical retrieved-learning resolution

Layer / File(s) Summary
Historical resolution contracts
reflexio/server/services/storage/lifecycle_filters.py, reflexio/server/services/storage/storage_base/...
Storage contracts define include_inactive semantics and reject combinations with explicit lifecycle filters.
Lifecycle-inclusive storage queries
reflexio/server/services/storage/sqlite_storage/playbook/*, reflexio/server/services/storage/sqlite_storage/profiles/*
Bulk profile and playbook lookups can include inactive rows while preserving ID and ownership constraints.
Resolvable evaluation persistence
reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py, tests/server/services/storage/test_storage_contract_retrieved_learning_evals.py
Evaluation persistence keeps attached references whose underlying rows still exist and clears results when none remain resolvable.
Historical candidate evaluation
reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py, tests/server/services/agent_success_evaluation/test_retrieved_learning_evaluator.py
Attached archived, expired, unapproved, and superseded learnings are evaluated using original IDs, while missing references are skipped.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Evaluator as RetrievedLearningEvaluator
  participant Profiles as ProfileStore
  participant UserPlaybooks as UserPlaybookStore
  participant AgentPlaybooks as AgentPlaybookStore
  participant EvalStore as RetrievedLearningEvaluationStore
  Evaluator->>Profiles: Resolve attached profiles including inactive rows
  Evaluator->>UserPlaybooks: Resolve attached user playbooks including inactive rows
  Evaluator->>AgentPlaybooks: Resolve attached agent playbooks including inactive rows
  Evaluator->>EvalStore: Replace retrieved-learning evaluation results
  EvalStore->>EvalStore: Keep attached references with existing underlying rows
Loading

Possibly related PRs

  • ReflexioAI/reflexio#329: Both changes modify retrieved-learning candidate resolution and persisted evaluation filtering.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: retrieved learnings are judged against their original rows.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rle-historical-resolution

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
reflexio/server/services/storage/sqlite_storage/playbook/_user.py (1)

544-574: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add # noqa: S608 to the include_inactive query for consistency.

Ruff flags this as a possible SQL injection vector, but it's a false positive — ph is a string of ? placeholders and all values are bound as parameters. The existing get_user_playbooks_by_ids_any_user on line 761 of this same file uses # noqa: S608 for the identical pattern. Adding it here keeps the file consistent and silences the linter.

♻️ Proposed fix
         if include_inactive:
             rows = self._fetchall(
                 "SELECT * FROM user_playbooks "
-                f"WHERE user_id = ? AND user_playbook_id IN ({ph})",
+                f"WHERE user_id = ? AND user_playbook_id IN ({ph})",  # noqa: S608
                 (user_id, *user_playbook_ids),
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/playbook/_user.py` around
lines 544 - 574, Add an inline # noqa: S608 suppression to the include_inactive
SQL query in get_user_playbooks_by_ids, matching the existing suppression in
get_user_playbooks_by_ids_any_user; leave the parameterized query behavior
unchanged.

Source: Linters/SAST tools

reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py (1)

674-680: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing # noqa: S608 breaks the "Ruff clean" claim.

The new f-string query is safe (only placeholder count is interpolated, values are parameterized), but this exact pattern is suppressed with # noqa: S608 elsewhere in this file (e.g. delete_profiles_by_ids). This line lacks that suppression and will be flagged by Ruff.

🔧 Proposed fix
         if include_inactive:
             rows = self._fetchall(
-                f"SELECT * FROM profiles WHERE user_id = ? AND profile_id IN ({ph})",
+                f"SELECT * FROM profiles WHERE user_id = ? AND profile_id IN ({ph})",  # noqa: S608
                 (user_id, *profile_ids),
             )
             return [_row_to_profile(r) for r in rows]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py`
around lines 674 - 680, Add the file’s established `# noqa: S608` suppression to
the interpolated SQL statement in the `include_inactive` branch of the
profile-fetching method, matching the existing pattern used by
`delete_profiles_by_ids`.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@reflexio/server/services/storage/sqlite_storage/playbook/_user.py`:
- Around line 544-574: Add an inline # noqa: S608 suppression to the
include_inactive SQL query in get_user_playbooks_by_ids, matching the existing
suppression in get_user_playbooks_by_ids_any_user; leave the parameterized query
behavior unchanged.

In `@reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py`:
- Around line 674-680: Add the file’s established `# noqa: S608` suppression to
the interpolated SQL statement in the `include_inactive` branch of the
profile-fetching method, matching the existing pattern used by
`delete_profiles_by_ids`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 08ff1e32-036e-4029-b6b0-d0bf434c716e

📥 Commits

Reviewing files that changed from the base of the PR and between 51a760a and 815b4b5.

📒 Files selected for processing (13)
  • reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py
  • reflexio/server/services/storage/lifecycle_filters.py
  • reflexio/server/services/storage/sqlite_storage/playbook/_agent.py
  • reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py
  • reflexio/server/services/storage/sqlite_storage/playbook/_user.py
  • reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py
  • reflexio/server/services/storage/storage_base/playbook/_agent.py
  • reflexio/server/services/storage/storage_base/playbook/_user.py
  • reflexio/server/services/storage/storage_base/profiles/_profile_store.py
  • tests/server/services/agent_success_evaluation/test_retrieved_learning_evaluator.py
  • tests/server/services/storage/test_storage_contract_playbook.py
  • tests/server/services/storage/test_storage_contract_profiles.py
  • tests/server/services/storage/test_storage_contract_retrieved_learning_evals.py

@yyiilluu
yyiilluu merged commit 85d848f into main Jul 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant