Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,29 @@ All notable changes to vouch are documented here. Format follows
per-prompt block, the session banner, `vouch status` and the opt-in
question all say so rather than calling it "this repo's" knowledge.

### Added
- **ingest selection knob (`vouch ingest --max-claims / --budget-chars`).**
capture used to file every substantive sentence of a source — complete,
but a restatement of the whole document rather than the facts worth a
claim. `extract.select_spans` ranks candidate spans by information density
(sum over distinct content words of `1 / document-frequency`, so rare
specific terms outweigh stopword-heavy filler) and keeps the best under a
claim-count or character budget. it is deterministic and llm-free — and it
only ever returns a *subset* of the verbatim spans, never a paraphrase, so
every kept claim's receipt still verifies by construction. unset, ingest
keeps every span exactly as before (the unbudgeted baseline is unchanged).
this is the selection step the compiler thesis needs: fewer, denser claims
are what move accuracy-per-token against the grep baseline.

### Fixed
- **extraction no longer fractures dotted numbers.** `segment_source` split
on every `.`, so a version or decimal (`6.8.3`, `3.14`) was broken across
segment boundaries and its answer atom fell out of every span — measured at
~11% of the ground-truth facts lost on a synthetic lookup corpus *before any
budget was applied*. a `.` flanked by digits is now kept inside the span
(sentence-ending periods are unaffected), lifting the recall ceiling of the
whole ingest pipeline from 89% to 100% of facts on that corpus.

## [1.5.0] — 2026-07-20

### Added
Expand Down
18 changes: 17 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1754,16 +1754,30 @@ def propose_claim_cmd(
"--no-approve", is_flag=True,
help="File the claims but never auto-approve, even if the receipt gate is on.",
)
@click.option(
"--max-claims", type=int, default=None,
help="Keep only the N most information-dense spans (density selection). "
"Unset captures every quotable span.",
)
@click.option(
"--budget-chars", type=int, default=None,
help="Keep the densest spans that fit within this many characters.",
)
@click.option("--json", "as_json", is_flag=True, help="Emit source id + counts as json.")
def ingest_cmd(
path: Path, title: str | None, no_approve: bool, as_json: bool
path: Path, title: str | None, no_approve: bool,
max_claims: int | None, budget_chars: int | None, as_json: bool,
) -> None:
"""Ingest a file as a source and extract receipt-backed claims from it.

With review.auto_approve_on_receipt on, every claim whose quote verifies
against the source is approved with no human -- "run vouch on a doc and it
just captures the knowledge." Without the gate the claims are filed pending
for review; a claim that cannot quote its source is never rubber-stamped.

--max-claims / --budget-chars bound the capture to the most informative
spans instead of restating the whole document -- the selection knob that
keeps the facts worth a claim and drops filler.
"""
from . import extract as extract_mod

Expand All @@ -1774,6 +1788,8 @@ def ingest_cmd(
proposed_by=_whoami(),
title=title or path.name,
auto_approve=not no_approve,
max_claims=max_claims,
budget_chars=budget_chars,
)
pending = sum(
1 for p in store.list_proposals(ProposalStatus.PENDING)
Expand Down
103 changes: 99 additions & 4 deletions src/vouch/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@

# Split on newlines and sentence-ending punctuation. Each match is kept as an
# exact substring of the source so its receipt verifies; only surrounding
# whitespace is stripped (the inner run stays contiguous in the source).
_SEGMENT_RE = re.compile(r"[^\n.!?]+[.!?]?")
# whitespace is stripped (the inner run stays contiguous in the source). A `.`
# flanked by digits is a decimal/version dot ("6.8.3", "3.14"), never a
# sentence boundary — keep it inside the span so the number-valued fact isn't
# fractured out of every claim.
_SEGMENT_RE = re.compile(r"(?:[^\n.!?]|(?<=\d)\.(?=\d))+[.!?]?")

DEFAULT_MIN_CHARS = 16
DEFAULT_MAX_CHARS = 320
Expand All @@ -44,6 +47,79 @@ def _is_noise(segment: str) -> bool:
return letters < len(segment) * _MIN_LETTER_RATIO


# Words that carry no fact: a span made mostly of them is filler, not
# knowledge. Kept local (not imported from synthesize) so the ingest hot path
# stays clear of the synthesis/LLM import chain.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
_STOPWORDS = frozenset(
{
"a", "an", "and", "are", "as", "at", "be", "by", "do", "does", "for",
"from", "how", "in", "into", "is", "it", "its", "of", "on", "or",
"that", "the", "their", "them", "then", "there", "these", "this", "to",
"was", "were", "what", "when", "where", "which", "who", "why", "will",
"with", "you", "your",
}
)


def _content_words(span: str) -> list[str]:
"""Lowercased alphanumeric tokens of ``span`` minus short words/stopwords."""
words: list[str] = []
for raw in span.split():
token = "".join(ch for ch in raw.lower() if ch.isalnum())
if len(token) < 3 or token in _STOPWORDS:
continue
words.append(token)
return words


def _span_score(span: str, doc_freq: dict[str, int]) -> float:
"""Information density of ``span``: sum over its distinct content words of
``1 / document-frequency``. A word repeated across many spans is less
discriminating, so it counts for less; a rare, specific term counts for
more. Stopword-heavy filler scores near zero.
"""
return sum(1.0 / doc_freq.get(word, 1) for word in set(_content_words(span)))


def select_spans(
segments: list[str],
*,
max_claims: int | None = None,
budget_chars: int | None = None,
) -> list[str]:
"""Keep the most information-dense spans under a budget, in source order.

Ranking is deterministic and llm-free (see ``_span_score``): fact-dense
sentences outrank stopword-heavy filler. With no budget the input is
returned unchanged — the unbudgeted baseline that captures every span.
Selection only ever returns a *subset* of ``segments``, never a rewrite, so
every kept span is still a verbatim substring of the source and its receipt
still verifies by construction.
"""
if max_claims is None and budget_chars is None:
return segments
doc_freq: dict[str, int] = {}
for seg in segments:
for word in set(_content_words(seg)):
doc_freq[word] = doc_freq.get(word, 0) + 1
# Rank by score (desc), ties broken by original position for determinism.
ranked = sorted(
range(len(segments)),
key=lambda i: (-_span_score(segments[i], doc_freq), i),
)
kept: list[int] = []
used = 0
for i in ranked:
if max_claims is not None and len(kept) >= max_claims:
break
length = len(segments[i])
if budget_chars is not None and used + length > budget_chars:
continue
kept.append(i)
used += length
return [segments[i] for i in sorted(kept)]
Comment on lines +99 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does the Click version declared by this repository support click.IntRange(min=0) as a click.option type?

💡 Result:

Yes, the Click library supports click.IntRange(min=0) as an option type [1]. click.IntRange is specifically designed to restrict integer input to a defined range [1][2]. When you use click.IntRange(min=0), you effectively create a parameter that accepts any integer greater than or equal to 0 [1]. If the user provides a value less than 0, Click will raise an error by default, unless the clamp=True option is also enabled, in which case the value would be clamped to the minimum boundary [1][2]. This functionality is a standard part of the Click API and is consistent across documented versions [1][2][3]. Top results: [1][2]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- src/vouch/extract.py (relevant section) ---\n'
sed -n '1,220p' src/vouch/extract.py

printf '\n--- src/vouch/cli.py (relevant option section) ---\n'
sed -n '1720,1785p' src/vouch/cli.py

printf '\n--- tests/test_extract.py (relevant tests) ---\n'
sed -n '1,260p' tests/test_extract.py

printf '\n--- search for select_spans usages ---\n'
rg -n "select_spans\(" -S src tests

Repository: vouchdev/vouch

Length of output: 243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('src/vouch/extract.py')
print('extract.py lines', sum(1 for _ in p.open()))
p = Path('src/vouch/cli.py')
print('cli.py lines', sum(1 for _ in p.open()))
p = Path('tests/test_extract.py')
print('test_extract.py lines', sum(1 for _ in p.open()))
PY

Repository: vouchdev/vouch

Length of output: 217


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Probe runtime semantics only if click is importable in the sandbox.
python3 - <<'PY'
try:
    import click
except Exception as e:
    print(f'click import failed: {e}')
    raise SystemExit(0)

opt = click.Option(['--n'], type=click.IntRange(min=0), default=None)
ctx = click.Context(click.Command('cmd', params=[opt]))

def parse(args):
    try:
        return opt.consume_value(ctx, opt.process_value(ctx, args))
    except Exception as e:
        return type(e).__name__, str(e)

print('click', click.__version__)
print('type', type(opt.type).__name__)
for value in [None, 0, 3, -1]:
    try:
        rv = opt.type.convert(value, opt, ctx) if value is not None else None
        print(value, '=>', rv)
    except Exception as e:
        print(value, '=>', type(e).__name__, e)
PY

Repository: vouchdev/vouch

Length of output: 433


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- src/vouch/extract.py (relevant section) ---'
sed -n '70,140p' src/vouch/extract.py

echo
echo '--- src/vouch/cli.py (relevant option section) ---'
sed -n '1748,1778p' src/vouch/cli.py

echo
echo '--- tests/test_extract.py (relevant tests) ---'
sed -n '96,190p' tests/test_extract.py

echo
echo '--- direct usage search ---'
rg -n "select_spans\(" src tests -S

Repository: vouchdev/vouch

Length of output: 8672


reject negative selection budgets.

max_claims < 0 or budget_chars < 0 silently produces an empty selection, so an invalid input looks like a valid empty result. reject negative values at the shared selector and surface a cli validation error.

  • src/vouch/extract.py#L81-L107: raise ValueError for negative non-None bounds so direct callers cannot bypass the check.
  • src/vouch/cli.py#L1757-L1765: switch these options to click.IntRange(min=0) so the cli rejects invalid input up front.
  • tests/test_extract.py#L103-L133 and tests/test_extract.py#L166-L185: add negative-bound coverage for the helper and the command.
📍 Affects 3 files
  • src/vouch/extract.py#L96-L117 (this comment)
  • src/vouch/cli.py#L1757-L1765
  • tests/test_extract.py#L103-L133
  • tests/test_extract.py#L166-L185
🤖 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 `@src/vouch/extract.py` around lines 96 - 117, Reject negative non-None bounds
in the shared selector in src/vouch/extract.py#L81-L107 by raising ValueError
before selection; update src/vouch/cli.py#L1757-L1765 to use
click.IntRange(min=0) for both options so the CLI reports validation errors, and
add negative-bound coverage for direct helper calls and the command in
src/vouch/extract.py#L81-L107, src/vouch/cli.py#L1757-L1765,
tests/test_extract.py#L103-L133, and tests/test_extract.py#L166-L185.



def segment_source(
text: str,
*,
Expand Down Expand Up @@ -75,17 +151,28 @@ def extract_receipt_claims(
*,
proposed_by: str,
limit: int | None = None,
max_claims: int | None = None,
budget_chars: int | None = None,
) -> list[ProposeClaimResult]:
"""File a receipt-backed claim for each quotable span in ``source_id``.

Each segment becomes a claim that quotes itself verbatim, so its receipt
verifies by construction. A segment that (defensively) fails the verbatim
check — e.g. non-UTF-8 bytes mangled on decode — is dropped by
``propose_quoted_claim``. Returns the proposals actually filed.

``max_claims``/``budget_chars`` turn on density selection (``select_spans``):
only the most informative spans are filed, so ingest keeps the facts worth a
claim and drops filler instead of restating the whole document. ``limit`` is
the older positional cap (first-N in document order, used by session-answer
capture) and still applies after selection.
"""
text = store.read_source_content(source_id).decode("utf-8", errors="replace")
spans = segment_source(text)
if max_claims is not None or budget_chars is not None:
spans = select_spans(spans, max_claims=max_claims, budget_chars=budget_chars)
filed: list[ProposeClaimResult] = []
for segment in segment_source(text):
for segment in spans:
if limit is not None and len(filed) >= limit:
break
result = propose_quoted_claim(
Expand All @@ -104,6 +191,8 @@ def ingest_source(
proposed_by: str,
title: str | None = None,
auto_approve: bool = True,
max_claims: int | None = None,
budget_chars: int | None = None,
) -> tuple[Source, list[Claim]]:
"""Run the whole capture loop on a document, no human in the loop.

Expand All @@ -112,8 +201,14 @@ def ingest_source(
approve every one whose receipt verifies. Returns the source and the claims
that became durable. With the gate off the claims are filed but left pending
for a human — the review gate is never silently bypassed.

``max_claims``/``budget_chars`` bound the capture to the most informative
spans (see ``extract_receipt_claims``); unset, every quotable span is kept.
"""
source = store.put_source(content, title=title, scope=default_scope(store))
extract_receipt_claims(store, source.id, proposed_by=proposed_by)
extract_receipt_claims(
store, source.id, proposed_by=proposed_by,
max_claims=max_claims, budget_chars=budget_chars,
)
approved = auto_approve_receipts(store) if auto_approve else []
return source, approved
117 changes: 117 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@

from __future__ import annotations

import json
from pathlib import Path

import pytest
from click.testing import CliRunner

from vouch import extract, index_db, receipts
from vouch.cli import cli
from vouch.models import ProposalStatus
from vouch.storage import KBStore

Expand All @@ -37,6 +40,18 @@ def test_segment_source_splits_into_verbatim_spans() -> None:
assert s in text


def test_segment_source_keeps_dotted_numbers_intact() -> None:
# a period between two digits is a decimal/version dot, never a sentence
# end — splitting on it fractures the answer atom out of every span.
text = (
"The deployed build of Harbor Digest is version 6.8.3 today. "
"The measured value of pi is about 3.14 in this document."
)
segs = extract.segment_source(text)
assert any("version 6.8.3 today" in s for s in segs)
assert any("3.14" in s for s in segs)


def test_segment_source_drops_short_noise_and_dupes() -> None:
text = (
"ok. The very same sentence appears here twice in a row now. "
Expand Down Expand Up @@ -78,3 +93,105 @@ def test_ingest_source_leaves_pending_when_gate_off(store: KBStore) -> None:
assert approved == []
# proposed and receipt-backed, but still waiting for a human.
assert len(store.list_proposals(ProposalStatus.PENDING)) == 3


# --- M1: selection / compression -----------------------------------------
# Every-sentence capture beats nothing but not grep; the fidelity number
# (accuracy / tokens) moves only when ingest keeps the fact-dense spans and
# drops filler. Selection ranks spans by information density and keeps the
# best under a budget — but only ever *returns a subset of the verbatim
# spans*, never a paraphrase, so every kept claim's receipt still verifies by
# construction.

# Two fact-dense sentences (distinct content words, a number, proper nouns)
# and two filler sentences (stopwords and repetition, near-zero new content).
DENSE_A = "Photosynthesis converts carbon dioxide and water into glucose using sunlight."
DENSE_B = "Mount Everest rises 8849 metres above sea level on the border of Nepal."
FILLER_A = "And so it is, and so it is, and so it is, and so it is once more."
FILLER_B = "That is just how it is and how it will be, more or less, as you know."
MIX = f"{DENSE_A} {FILLER_A} {DENSE_B} {FILLER_B}".encode()


def test_select_spans_without_budget_returns_all() -> None:
segs = ["Alpha beta gamma delta epsilon.", "One two three four five."]
# no cap == today's behaviour: the unbudgeted baseline is left untouched.
assert extract.select_spans(segs) == segs


def test_select_spans_keeps_densest_under_max_claims() -> None:
segs = [DENSE_A, FILLER_A, DENSE_B, FILLER_B]
kept = extract.select_spans(segs, max_claims=2)
# the two fact-dense spans survive, filler is dropped, source order kept.
assert kept == [DENSE_A, DENSE_B]


def test_select_spans_respects_char_budget() -> None:
segs = [FILLER_A, DENSE_A]
kept = extract.select_spans(segs, budget_chars=len(DENSE_A))
assert kept == [DENSE_A]
assert sum(len(s) for s in kept) <= len(DENSE_A)


def test_select_spans_preserves_source_order_and_is_deterministic() -> None:
a = "Glucose stores chemical energy inside living plant cells."
b = "Chlorophyll absorbs red and blue wavelengths of visible light."
c = "Mitochondria release that stored energy during aerobic respiration."
segs = [a, b, c]
once = extract.select_spans(segs, max_claims=2)
twice = extract.select_spans(segs, max_claims=2)
assert once == twice # deterministic, llm-free
# whichever two win, they come back in their original document order.
assert once == [s for s in segs if s in once]


def test_extract_receipt_claims_max_claims_selects_densest_and_verifies(
store: KBStore,
) -> None:
src = store.put_source(MIX)
filed = extract.extract_receipt_claims(
store, src.id, proposed_by="agent", max_claims=2
)
assert len(filed) == 2
texts = {store.get_proposal(res.id).payload["text"] for res in filed}
assert DENSE_A in texts and DENSE_B in texts # density selection, not truncation
assert FILLER_A not in texts and FILLER_B not in texts
# selecting a subset never breaks the receipt: kept spans stay verbatim.
for res in filed:
evidence_ids = store.get_proposal(res.id).payload["evidence"]
assert receipts.evaluate_claim_receipts(store, evidence_ids).approve


def test_ingest_source_budget_compresses_and_stays_recallable(store: KBStore) -> None:
store.config_path.write_text(
"review:\n auto_approve_on_receipt: true\n", encoding="utf-8"
)
_src, approved = extract.ingest_source(
store, MIX, proposed_by="agent", max_claims=2
)
# compressed: fewer claims than the four spans, and only the dense ones.
assert len(approved) == 2
assert len(store.list_claims()) == 2
hits = index_db.search(store.kb_dir, "photosynthesis")
assert any(k == "claim" for k, *_ in hits)


def test_cli_ingest_max_claims_compresses(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "home"))
monkeypatch.delenv("VOUCH_KB_PATH", raising=False)
monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False)
proj = tmp_path / "proj"
proj.mkdir()
store = KBStore.init(proj)
store.config_path.write_text(
"review:\n auto_approve_on_receipt: true\n", encoding="utf-8"
)
doc = proj / "doc.txt"
doc.write_bytes(MIX)
monkeypatch.chdir(proj)
r = CliRunner().invoke(cli, ["ingest", str(doc), "--max-claims", "2", "--json"])
assert r.exit_code == 0, r.output
payload = json.loads(r.output)
assert payload["approved"] == 2
assert len(store.list_claims()) == 2
Loading