diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index c0449806b..1179d42b0 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -18,6 +18,7 @@ DB resolves from `$AELFRICE_DB`, then `/aelfrice/memory.db` when | `search [--budget N]` | L0 locked + L2.5 entity-index (v1.3+) + L1 FTS5 BM25, token-budgeted (default 2,400 at v1.3+, 2,000 prior). L2.5 default-on; disable via `[retrieval] entity_index_enabled = false` in `.aelfrice.toml` or `AELFRICE_ENTITY_INDEX=0` in the env. Distinguishes "store empty" from "no match". | | `lock ` | Insert at `(α, β) = (9.0, 0.5)` with `lock_level=user`. Idempotent — re-lock upgrades existing. | | `locked [--pressured]` | List locks. With `--pressured`, only those with `demotion_pressure > 0`. | +| `core [--json] [--limit N] [--min-corroboration N] [--min-posterior FLOAT] [--min-alpha-beta N] [--locked-only] [--no-locked]` | (v2.0+, #439) Surface load-bearing beliefs: locked ∪ {corroboration ≥ 2} ∪ {posterior ≥ 2/3 with α+β ≥ 4}. Read-only. | | `unlock ` | Drop a user-lock without changing origin. Idempotent. Writes a `lock:unlock` audit row. | | `delete [--yes] [--force]` | Hard-delete a belief: removes the belief row, FTS entry, edges (src and dst), and entity index rows. Writes one audit row to `feedback_history` (valence=-1.0, source=`user_deleted`) before the cascade so the forensic record survives. Confirmation prompt by default — prints belief content and requires the user to type the first 8 characters of the id; `--yes` skips the prompt. Refuses locked (`lock_level=user`) beliefs without `--force`; with `--force` the audit source becomes `user_deleted_force`. Exit 0 on success; exit 1 on not-found, locked-without-force, or prompt mismatch. | | `demote ` | Drop a user lock (one tier per call: lock first, then user_validated). Delegates to `unlock` for the lock-drop path so an audit row is always written. | diff --git a/src/aelfrice/cli.py b/src/aelfrice/cli.py index 0a63d1579..8901d093d 100644 --- a/src/aelfrice/cli.py +++ b/src/aelfrice/cli.py @@ -65,6 +65,7 @@ EDGE_SUPERSEDES, EDGE_SUPPORTS, INGEST_SOURCE_CLI_REMEMBER, + LOCK_NONE, LOCK_USER, ORIGIN_USER_STATED, ORIGIN_USER_VALIDATED, @@ -1278,6 +1279,116 @@ def _cmd_confirm(args: argparse.Namespace, out: object) -> int: return 0 +_CORE_MIN_CORROBORATION: int = 2 +_CORE_MIN_POSTERIOR: float = 2.0 / 3.0 +_CORE_MIN_ALPHA_BETA: int = 4 + + +def _qualifies_core(b: object, args: argparse.Namespace) -> bool: + """Return True if belief b meets any non-lock core signal.""" + alpha: float = b.alpha # type: ignore[attr-defined] + beta: float = b.beta # type: ignore[attr-defined] + corr: int = b.corroboration_count # type: ignore[attr-defined] + if corr >= args.min_corroboration: + return True + ab = alpha + beta + if ab > 0 and ab >= args.min_alpha_beta and (alpha / ab) >= args.min_posterior: + return True + return False + + +def _emit_core( + locked: list[object], + candidates: list[object], + args: argparse.Namespace, + out: object, +) -> None: + seen: set[str] = {b.id for b in locked} # type: ignore[attr-defined] + unlocked = [b for b in candidates if b.id not in seen] # type: ignore[attr-defined] + + def _posterior(b: object) -> float: + a: float = b.alpha # type: ignore[attr-defined] + bb: float = b.beta # type: ignore[attr-defined] + ab = a + bb + return a / ab if ab > 0 else 0.0 + + unlocked.sort(key=lambda b: (-_posterior(b), b.id)) # type: ignore[attr-defined] + results = list(locked) + unlocked + + if args.limit is not None: + results = results[: args.limit] + + if not results: + print("no core beliefs", file=out) # type: ignore[arg-type] + return + + if args.json: + rows = [] + for b in results: + signals: list[str] = [] + if b.lock_level != LOCK_NONE: # type: ignore[attr-defined] + signals.append("lock") + if b.corroboration_count >= args.min_corroboration: # type: ignore[attr-defined] + signals.append("corroboration") + alpha: float = b.alpha # type: ignore[attr-defined] + beta: float = b.beta # type: ignore[attr-defined] + ab = alpha + beta + if ab > 0 and ab >= args.min_alpha_beta and (alpha / ab) >= args.min_posterior: + signals.append("posterior") + rows.append({ + "id": b.id, # type: ignore[attr-defined] + "content": b.content, # type: ignore[attr-defined] + "lock_level": b.lock_level, # type: ignore[attr-defined] + "alpha": alpha, + "beta": beta, + "posterior_mean": round(alpha / ab, 3) if ab else 0.0, + "corroboration_count": b.corroboration_count, # type: ignore[attr-defined] + "signals": sorted(set(signals)), + }) + print(json.dumps(rows, indent=2), file=out) # type: ignore[arg-type] + return + + for b in results: + alpha = b.alpha # type: ignore[attr-defined] + beta = b.beta # type: ignore[attr-defined] + corr = b.corroboration_count # type: ignore[attr-defined] + ab = alpha + beta + parts: list[str] = [] + if b.lock_level != LOCK_NONE: # type: ignore[attr-defined] + parts.append("LOCK") + if corr >= args.min_corroboration: + parts.append(f"CORR={corr}") + if ab > 0 and ab >= args.min_alpha_beta and (alpha / ab) >= args.min_posterior: + parts.append(f"α={alpha:.1f}") + parts.append(f"β={beta:.1f}") + parts.append(f"μ={alpha / ab:.3f}") + tag = f" [{','.join(parts)}]" if parts else "" + print(f"{b.id}{tag}: {b.content}", file=out) # type: ignore[arg-type, attr-defined] + + +def _cmd_core(args: argparse.Namespace, out: object) -> int: + """Surface load-bearing beliefs: locked ∪ corroborated ∪ high-posterior. + + Spec: docs/feature-aelf-core.md. No new store method — composition over + list_locked_beliefs(), list_belief_ids(), and get_belief(). + """ + store = _open_store() + try: + locked: list[object] = [] if args.no_locked else store.list_locked_beliefs() + candidates: list[object] = [] + if not args.locked_only: + for bid in store.list_belief_ids(): + b = store.get_belief(bid) + if b is None or b.lock_level != LOCK_NONE: + continue + if _qualifies_core(b, args): + candidates.append(b) + finally: + store.close() + _emit_core(locked, candidates, args, out) + return 0 + + def _cmd_promote(args: argparse.Namespace, out: object) -> int: """Promote agent_inferred -> user_validated. Alias of `aelf validate`.""" return _cmd_validate(args, out) @@ -3493,6 +3604,47 @@ def build_parser(*, show_advanced: bool = False) -> argparse.ArgumentParser: ) p_locked.set_defaults(func=_cmd_locked) + # Read-only lens: load-bearing beliefs (locked ∪ corroborated ∪ high-posterior). + p_core = sub.add_parser( + "core", + help="surface load-bearing beliefs: locked ∪ corroborated ∪ high-posterior", + ) + p_core.add_argument( + "--json", action="store_true", dest="json", + help="emit JSON list instead of text", + ) + p_core.add_argument( + "--limit", type=int, default=None, metavar="N", + help="cap result count after filtering/sort", + ) + p_core.add_argument( + "--min-corroboration", type=int, default=_CORE_MIN_CORROBORATION, + metavar="N", + help="corroboration_count threshold (default 2; lower to widen " + "lens — 0 admits any non-negative count)", + ) + p_core.add_argument( + "--min-posterior", type=float, default=_CORE_MIN_POSTERIOR, + metavar="FLOAT", + help="posterior-mean threshold (default 2/3; lower to widen " + "lens — 0.0 admits any belief that passes --min-alpha-beta)", + ) + p_core.add_argument( + "--min-alpha-beta", type=int, default=_CORE_MIN_ALPHA_BETA, + metavar="N", + help="α+β co-gate for posterior signal (default 4)", + ) + _core_excl = p_core.add_mutually_exclusive_group() + _core_excl.add_argument( + "--locked-only", action="store_true", + help="return only the locked subset", + ) + _core_excl.add_argument( + "--no-locked", action="store_true", + help="suppress locked beliefs (surface corroboration/posterior only)", + ) + p_core.set_defaults(func=_cmd_core) + # Hidden: belief lifecycle inverse of `lock` / `validate`. Power-user. p_demote = sub.add_parser( "demote", diff --git a/src/aelfrice/slash_commands/core.md b/src/aelfrice/slash_commands/core.md new file mode 100644 index 000000000..e2081dec8 --- /dev/null +++ b/src/aelfrice/slash_commands/core.md @@ -0,0 +1,18 @@ +--- +name: aelf:core +description: Surface load-bearing beliefs — locked ∪ corroborated (≥2 sources) ∪ high-posterior (μ ≥ 2/3, α+β ≥ 4). Read-only. +argument-hint: Optional flags, e.g. --json or --locked-only +allowed-tools: + - Bash +--- + +List the beliefs that anchor the store: any belief that is user-locked, +independently corroborated from at least two sources, or has a strong +multi-event positive posterior. This is the operator's first-look lens +when checking whether the store foundation is healthy. + + + +Run: `uv run aelf core $ARGUMENTS` +Display the output verbatim. Do not add commentary. + diff --git a/tests/test_cli_core.py b/tests/test_cli_core.py new file mode 100644 index 000000000..2360ca48d --- /dev/null +++ b/tests/test_cli_core.py @@ -0,0 +1,334 @@ +"""Tests for `aelf core` CLI subcommand (#439). + +Five-belief fixture matrix per spec (docs/feature-aelf-core.md): + b-locked lock=user alpha=1 beta=1 corr=0 → yes (LOCK) + b-corr lock=none alpha=1 beta=1 corr=3 → yes (CORR) + b-posterior lock=none alpha=4 beta=1 corr=0 → yes (μ=0.8, α+β=5) + b-thin-post lock=none alpha=2 beta=1 corr=0 → no (μ=0.667 but α+β=3) + b-prior lock=none alpha=1 beta=1 corr=0 → no + +Tests cover all 8 spec scenarios. +""" +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from aelfrice.cli import main +from aelfrice.models import ( + BELIEF_FACTUAL, + CORROBORATION_SOURCE_CLI_REMEMBER, + LOCK_NONE, + LOCK_USER, + ORIGIN_AGENT_INFERRED, + Belief, +) +from aelfrice.store import MemoryStore + + +@pytest.fixture(autouse=True) +def isolated_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + p = tmp_path / "aelf.db" + monkeypatch.setenv("AELFRICE_DB", str(p)) + return p + + +def _run(*argv: str) -> tuple[int, str]: + buf = io.StringIO() + code = main(argv=list(argv), out=buf) + return code, buf.getvalue() + + +def _make_belief( + bid: str, + content: str, + lock_level: str = LOCK_NONE, + alpha: float = 1.0, + beta: float = 1.0, +) -> Belief: + return Belief( + id=bid, + content=content, + content_hash="hash_" + bid[:6], + alpha=alpha, + beta=beta, + type=BELIEF_FACTUAL, + lock_level=lock_level, + locked_at="2026-05-06T00:00:00Z" if lock_level != LOCK_NONE else None, + demotion_pressure=0, + created_at="2026-05-06T00:00:00Z", + last_retrieved_at=None, + origin=ORIGIN_AGENT_INFERRED, + ) + + +def _seed_store(db: Path) -> dict[str, Belief]: + beliefs = { + "b-locked": _make_belief("b0locked0000000000", "locked ground truth", lock_level=LOCK_USER), + "b-corr": _make_belief("b0corr00000000000a", "independently corroborated"), + "b-posterior": _make_belief("b0posterior000000a", "strong posterior", alpha=4.0, beta=1.0), + "b-thin": _make_belief("b0thinpost000000aa", "thin posterior", alpha=2.0, beta=1.0), + "b-prior": _make_belief("b0prior000000000aa", "flat prior"), + } + s = MemoryStore(str(db)) + try: + for b in beliefs.values(): + s.insert_belief(b) + # b-corr: record 3 corroboration rows via the public API + for _ in range(3): + s.record_corroboration( + beliefs["b-corr"].id, + source_type=CORROBORATION_SOURCE_CLI_REMEMBER, + ) + finally: + s.close() + return beliefs + + +# --- scenario 1: default text output ----------------------------------------- + + +def test_core_default_includes_locked(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + code, out = _run("core") + assert code == 0 + assert b["b-locked"].id in out + + +def test_core_default_includes_corroborated(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + code, out = _run("core") + assert code == 0 + assert b["b-corr"].id in out + + +def test_core_default_includes_posterior(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + code, out = _run("core") + assert code == 0 + assert b["b-posterior"].id in out + + +def test_core_default_excludes_thin_posterior(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core") + assert b["b-thin"].id not in out + + +def test_core_default_excludes_flat_prior(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core") + assert b["b-prior"].id not in out + + +def test_core_tag_block_lock(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core") + assert f"{b['b-locked'].id} [LOCK]" in out + + +def test_core_tag_block_corr(isolated_db: Path) -> None: + _seed_store(isolated_db) + _, out = _run("core") + assert "CORR=3" in out + + +def test_core_tag_block_posterior(isolated_db: Path) -> None: + _seed_store(isolated_db) + _, out = _run("core") + assert "μ=0.800" in out + + +# --- scenario 2: --json round-trip ------------------------------------------- + + +def test_core_json_parses(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--json") + rows = json.loads(out) + assert isinstance(rows, list) + # JSON output must apply the same core-filter as text mode: + # b-thin (μ=0.667, α+β=3) and b-prior (μ=0.5, α+β=2) are excluded + # at default thresholds. + row_ids = {row["id"] for row in rows} + assert b["b-thin"].id not in row_ids + assert b["b-prior"].id not in row_ids + + +def test_core_json_signals_lock(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--json") + rows = json.loads(out) + locked_row = next(r for r in rows if r["id"] == b["b-locked"].id) + assert "lock" in locked_row["signals"] + + +def test_core_json_signals_corroboration(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--json") + rows = json.loads(out) + corr_row = next(r for r in rows if r["id"] == b["b-corr"].id) + assert "corroboration" in corr_row["signals"] + + +def test_core_json_signals_posterior(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--json") + rows = json.loads(out) + post_row = next(r for r in rows if r["id"] == b["b-posterior"].id) + assert "posterior" in post_row["signals"] + + +def test_core_json_signals_nonempty(isolated_db: Path) -> None: + _seed_store(isolated_db) + _, out = _run("core", "--json") + rows = json.loads(out) + for row in rows: + assert len(row["signals"]) >= 1 + + +# --- scenario 3: --locked-only ----------------------------------------------- + + +def test_core_locked_only_exits_zero(isolated_db: Path) -> None: + _seed_store(isolated_db) + code, _ = _run("core", "--locked-only") + assert code == 0 + + +def test_core_locked_only_returns_locked(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--locked-only") + assert b["b-locked"].id in out + + +def test_core_locked_only_excludes_corr(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--locked-only") + assert b["b-corr"].id not in out + + +def test_core_locked_only_excludes_posterior(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--locked-only") + assert b["b-posterior"].id not in out + + +# --- scenario 4: --no-locked ------------------------------------------------- + + +def test_core_no_locked_suppresses_locked(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--no-locked") + assert b["b-locked"].id not in out + + +def test_core_no_locked_includes_corr(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--no-locked") + assert b["b-corr"].id in out + + +def test_core_no_locked_includes_posterior(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--no-locked") + assert b["b-posterior"].id in out + + +# --- scenario 5: mutual exclusion exit 2 ------------------------------------- + + +def test_core_locked_only_and_no_locked_exit_two( + isolated_db: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc: + _run("core", "--locked-only", "--no-locked") + assert exc.value.code == 2 + + +# --- scenario 6: --limit 1 returns locked first ------------------------------ + + +def test_core_limit_one_returns_locked(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--limit", "1") + assert b["b-locked"].id in out + assert b["b-corr"].id not in out + assert b["b-posterior"].id not in out + + +# --- scenario 7: empty store prints sentinel ---------------------------------- + + +def test_core_empty_store_exits_zero(isolated_db: Path) -> None: + code, _ = _run("core") + assert code == 0 + + +def test_core_empty_store_message(isolated_db: Path) -> None: + _, out = _run("core") + assert out.strip() == "no core beliefs" + + +# --- scenario 8: threshold flags --------------------------------------------- + + +def test_core_min_corroboration_raised_drops_corr(isolated_db: Path) -> None: + b = _seed_store(isolated_db) + _, out = _run("core", "--min-corroboration", "4") + assert b["b-corr"].id not in out + + +def test_core_disabled_posterior_and_corr_includes_all_nonprior(isolated_db: Path) -> None: + """--min-posterior 0.0 --min-alpha-beta 0 includes b-thin (μ=0.667, α+β=3).""" + b = _seed_store(isolated_db) + _, out = _run("core", "--min-posterior", "0.0", "--min-alpha-beta", "0") + assert b["b-thin"].id in out + + +# --- scenario 9: defensive — α+β==0 must not crash sort ---------------------- + + +def test_core_zero_alpha_beta_does_not_crash(isolated_db: Path) -> None: + """Belief with alpha=beta=0 must not raise ZeroDivisionError in sort path.""" + s = MemoryStore(str(isolated_db)) + try: + s.insert_belief( + _make_belief("b0zeroab0000000000", "zero ab edge case", alpha=0.0, beta=0.0), + ) + s.insert_belief( + _make_belief( + "b0postlive00000000", + "live posterior", + alpha=4.0, + beta=1.0, + ), + ) + finally: + s.close() + code, out = _run("core") + assert code == 0 + assert "b0postlive00000000" in out + + +def test_core_zero_alpha_beta_with_min_ab_zero_does_not_crash( + isolated_db: Path, +) -> None: + """`--min-alpha-beta 0` must not enable the alpha/ab division on an α+β==0 belief. + + The default test above passes only because `min_alpha_beta=4` short-circuits + the qualify check before the division. This variant exercises the actual + unguarded sites at the JSON and text emission paths. + """ + s = MemoryStore(str(isolated_db)) + try: + s.insert_belief( + _make_belief("b0zeroab0000000000", "zero ab edge case", alpha=0.0, beta=0.0), + ) + finally: + s.close() + code, _ = _run("core", "--min-alpha-beta", "0") + assert code == 0 diff --git a/tests/test_slash_commands.py b/tests/test_slash_commands.py index 2c429293c..f6d2a1951 100644 --- a/tests/test_slash_commands.py +++ b/tests/test_slash_commands.py @@ -49,6 +49,8 @@ # test_slash_commands_match_visible_cli_subcommands both enforce the # slash file exists and matches the CLI surface. "delete", + # v2.0 (#439) — load-bearing belief lens (locked ∪ corroborated ∪ high-posterior). + "core", )