flyleaf 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flyleaf
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Local inventory of AI components in a repository. Not a legal risk classifier.
5
5
  Keywords: eu-ai-act,inventory,model-card,cli
6
6
  Author: Krishna Dahale
@@ -26,10 +26,10 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # flyleaf
28
28
 
29
- [![ci](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml/badge.svg)](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml)
30
- [![pypi](https://img.shields.io/pypi/v/flyleaf)](https://pypi.org/project/flyleaf/)
31
- [![python](https://img.shields.io/pypi/pyversions/flyleaf)](https://pypi.org/project/flyleaf/)
32
- [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
29
+ [![CI status](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml/badge.svg)](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml)
30
+ [![PyPI version](https://img.shields.io/pypi/v/flyleaf?cacheSeconds=3600)](https://pypi.org/project/flyleaf/)
31
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/flyleaf?cacheSeconds=3600)](https://pypi.org/project/flyleaf/)
32
+ [![License](https://img.shields.io/pypi/l/flyleaf?cacheSeconds=3600)](https://github.com/krishyaid-coder/flyleaf/blob/main/LICENSE)
33
33
 
34
34
  <p align="center">
35
35
  <img src="https://raw.githubusercontent.com/krishyaid-coder/flyleaf/main/flyleaf.png" alt="flyleaf" width="220">
@@ -58,6 +58,7 @@ flowchart TD
58
58
  scan[flyleaf scan]
59
59
  inventory[Inventory]
60
60
  brief[flyleaf brief]
61
+ fit[flyleaf fit]
61
62
  card[flyleaf card]
62
63
  cite[flyleaf cite]
63
64
  person[Person decides]
@@ -71,6 +72,9 @@ flowchart TD
71
72
  pack --> brief
72
73
  inventory --> card
73
74
  pack --> cite
75
+ inventory --> fit
76
+ brief --> fit
77
+ fit --> person
74
78
  inventory --> person
75
79
  brief --> person
76
80
  card --> person
@@ -358,7 +362,7 @@ steps:
358
362
  - uses: actions/checkout@v7
359
363
  with:
360
364
  fetch-depth: 0
361
- - uses: krishyaid-coder/flyleaf@v0.5.0
365
+ - uses: krishyaid-coder/flyleaf@v0.6.0
362
366
  id: flyleaf
363
367
  with:
364
368
  baseline: "true"
@@ -389,6 +393,52 @@ uv run flyleaf render brief.json --format sarif -o brief.sarif
389
393
 
390
394
  `render` re-renders a saved report without scanning or reading git, so a pipeline that wants three formats pays for one scan, and an archived brief can be read back long after the branch is gone.
391
395
 
396
+ ## Fit
397
+
398
+ A brief can be longer than the space an assistant can hold. `fit` chooses which findings earn that space, and writes down what was left out and why. It does not store anything. The findings stay in the report you already saved.
399
+
400
+ <p align="center">
401
+ <img src="https://raw.githubusercontent.com/krishyaid-coder/flyleaf/main/fit.gif" alt="flyleaf fit packing short useful findings instead of one long lookalike, and saying why the lookalike was left out" width="820">
402
+ </p>
403
+
404
+ Worth, on a brief or an inventory, is engineering attention: high is 3, medium is 2, low is 1. A missing model card is 3 and a present card is 1. That number is not a legal risk tier. Pass `--query` when similarity should follow the question the assistant is about to answer. Without a query, similarity ranks by the same attention density uses, and the two still differ, because density weighs that attention against how many tokens the finding costs.
405
+
406
+ ```bash
407
+ uv run flyleaf brief main~1 --format json -o brief.json
408
+ uv run flyleaf fit brief.json --budget 800
409
+ uv run flyleaf fit brief.json --budget 800 --strategy similarity --query "model card"
410
+ uv run flyleaf fit --benchmark
411
+ ```
412
+
413
+ Three packers:
414
+
415
+ | Strategy | What it does |
416
+ |---|---|
417
+ | `density` | The default. Maximises total worth. While the pile is small it checks the combinations and keeps the best one. Past about 250,000 finding-by-token steps it packs by worth per token, and the report says which one it used. |
418
+ | `greedy` | Always the shortcut. Best worth per token first, skip anything that no longer fits, and never go back to ask whether skipping an earlier item would have left room for a better pair. |
419
+ | `similarity` | The usual approach. Most relevant first, then the next, until the budget is gone. Size versus worth is ignored. |
420
+
421
+ `--benchmark` runs the cases that separate them.
422
+
423
+ **Short gems.** Budget 100. Four short items cost 25 tokens and are worth 0.80 each, but they only look mildly relevant (0.20). One long item costs 90, is worth 0.45, and looks extremely relevant (0.98). Similarity takes the long one and has nothing left that fits: worth 0.45. Density takes the four short ones: worth 3.20.
424
+
425
+ **Awkward pack.** Budget 10. One item costs 6 and is worth 6, so its rate is 1.0, and it is also the most relevant. Two others cost 5 and are worth 4, a rate of 0.8. Greedy takes the 6 and cannot fit either partner: worth 6. Density takes the pair: worth 8. Similarity takes the single item too.
426
+
427
+ **Equal length.** Three items, 10 tokens each, budget 20, and worth matches relevance (0.9, 0.5, 0.1). Every strategy takes the 0.9 and the 0.5. Worth 1.40. Packing from the top was already right.
428
+
429
+ A citation named by a finding is part of that finding's size, estimated at about four characters per token unless the memories file already gives a count. Two findings that cite the same provision each pay for the quote. The kept text is what `fit` would hand the assistant.
430
+
431
+ A memories file packs scores you supply, which is how the benchmark is built. Waived findings are not packed. They stay waived.
432
+
433
+ ```json
434
+ {
435
+ "kind": "memories",
436
+ "memories": [
437
+ {"id": "name", "text": "The user's name is Ada.", "tokens": 8, "value": 0.95, "relevance": 0.70}
438
+ ]
439
+ }
440
+ ```
441
+
392
442
  ## Card
393
443
 
394
444
  ```bash
@@ -433,6 +483,8 @@ A declared system is only as good as its `includes` patterns. A new file that no
433
483
 
434
484
  `brief` compares the text of detected evidence, not line numbers, so moving code does not raise a finding. A change elsewhere in a manifest does not raise one either. A rename does count as a removal plus an addition.
435
485
 
486
+ `fit` estimates tokens at about four characters per token. That is not a model's tokenizer. A finding that cites a long provision costs those characters even when another finding cites the same text.
487
+
436
488
  ## Privacy
437
489
 
438
490
  Scanning reads the local tree and, for `brief`, local git history including `git blame`. There is no telemetry, no account, and no network call. The GitHub Action is the one place anything is sent anywhere, and it posts to your own repository with your own token. Use `--no-blame` to keep committer names out of a report.
@@ -1,9 +1,9 @@
1
1
  # flyleaf
2
2
 
3
- [![ci](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml/badge.svg)](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml)
4
- [![pypi](https://img.shields.io/pypi/v/flyleaf)](https://pypi.org/project/flyleaf/)
5
- [![python](https://img.shields.io/pypi/pyversions/flyleaf)](https://pypi.org/project/flyleaf/)
6
- [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
3
+ [![CI status](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml/badge.svg)](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml)
4
+ [![PyPI version](https://img.shields.io/pypi/v/flyleaf?cacheSeconds=3600)](https://pypi.org/project/flyleaf/)
5
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/flyleaf?cacheSeconds=3600)](https://pypi.org/project/flyleaf/)
6
+ [![License](https://img.shields.io/pypi/l/flyleaf?cacheSeconds=3600)](https://github.com/krishyaid-coder/flyleaf/blob/main/LICENSE)
7
7
 
8
8
  <p align="center">
9
9
  <img src="https://raw.githubusercontent.com/krishyaid-coder/flyleaf/main/flyleaf.png" alt="flyleaf" width="220">
@@ -32,6 +32,7 @@ flowchart TD
32
32
  scan[flyleaf scan]
33
33
  inventory[Inventory]
34
34
  brief[flyleaf brief]
35
+ fit[flyleaf fit]
35
36
  card[flyleaf card]
36
37
  cite[flyleaf cite]
37
38
  person[Person decides]
@@ -45,6 +46,9 @@ flowchart TD
45
46
  pack --> brief
46
47
  inventory --> card
47
48
  pack --> cite
49
+ inventory --> fit
50
+ brief --> fit
51
+ fit --> person
48
52
  inventory --> person
49
53
  brief --> person
50
54
  card --> person
@@ -332,7 +336,7 @@ steps:
332
336
  - uses: actions/checkout@v7
333
337
  with:
334
338
  fetch-depth: 0
335
- - uses: krishyaid-coder/flyleaf@v0.5.0
339
+ - uses: krishyaid-coder/flyleaf@v0.6.0
336
340
  id: flyleaf
337
341
  with:
338
342
  baseline: "true"
@@ -363,6 +367,52 @@ uv run flyleaf render brief.json --format sarif -o brief.sarif
363
367
 
364
368
  `render` re-renders a saved report without scanning or reading git, so a pipeline that wants three formats pays for one scan, and an archived brief can be read back long after the branch is gone.
365
369
 
370
+ ## Fit
371
+
372
+ A brief can be longer than the space an assistant can hold. `fit` chooses which findings earn that space, and writes down what was left out and why. It does not store anything. The findings stay in the report you already saved.
373
+
374
+ <p align="center">
375
+ <img src="https://raw.githubusercontent.com/krishyaid-coder/flyleaf/main/fit.gif" alt="flyleaf fit packing short useful findings instead of one long lookalike, and saying why the lookalike was left out" width="820">
376
+ </p>
377
+
378
+ Worth, on a brief or an inventory, is engineering attention: high is 3, medium is 2, low is 1. A missing model card is 3 and a present card is 1. That number is not a legal risk tier. Pass `--query` when similarity should follow the question the assistant is about to answer. Without a query, similarity ranks by the same attention density uses, and the two still differ, because density weighs that attention against how many tokens the finding costs.
379
+
380
+ ```bash
381
+ uv run flyleaf brief main~1 --format json -o brief.json
382
+ uv run flyleaf fit brief.json --budget 800
383
+ uv run flyleaf fit brief.json --budget 800 --strategy similarity --query "model card"
384
+ uv run flyleaf fit --benchmark
385
+ ```
386
+
387
+ Three packers:
388
+
389
+ | Strategy | What it does |
390
+ |---|---|
391
+ | `density` | The default. Maximises total worth. While the pile is small it checks the combinations and keeps the best one. Past about 250,000 finding-by-token steps it packs by worth per token, and the report says which one it used. |
392
+ | `greedy` | Always the shortcut. Best worth per token first, skip anything that no longer fits, and never go back to ask whether skipping an earlier item would have left room for a better pair. |
393
+ | `similarity` | The usual approach. Most relevant first, then the next, until the budget is gone. Size versus worth is ignored. |
394
+
395
+ `--benchmark` runs the cases that separate them.
396
+
397
+ **Short gems.** Budget 100. Four short items cost 25 tokens and are worth 0.80 each, but they only look mildly relevant (0.20). One long item costs 90, is worth 0.45, and looks extremely relevant (0.98). Similarity takes the long one and has nothing left that fits: worth 0.45. Density takes the four short ones: worth 3.20.
398
+
399
+ **Awkward pack.** Budget 10. One item costs 6 and is worth 6, so its rate is 1.0, and it is also the most relevant. Two others cost 5 and are worth 4, a rate of 0.8. Greedy takes the 6 and cannot fit either partner: worth 6. Density takes the pair: worth 8. Similarity takes the single item too.
400
+
401
+ **Equal length.** Three items, 10 tokens each, budget 20, and worth matches relevance (0.9, 0.5, 0.1). Every strategy takes the 0.9 and the 0.5. Worth 1.40. Packing from the top was already right.
402
+
403
+ A citation named by a finding is part of that finding's size, estimated at about four characters per token unless the memories file already gives a count. Two findings that cite the same provision each pay for the quote. The kept text is what `fit` would hand the assistant.
404
+
405
+ A memories file packs scores you supply, which is how the benchmark is built. Waived findings are not packed. They stay waived.
406
+
407
+ ```json
408
+ {
409
+ "kind": "memories",
410
+ "memories": [
411
+ {"id": "name", "text": "The user's name is Ada.", "tokens": 8, "value": 0.95, "relevance": 0.70}
412
+ ]
413
+ }
414
+ ```
415
+
366
416
  ## Card
367
417
 
368
418
  ```bash
@@ -407,6 +457,8 @@ A declared system is only as good as its `includes` patterns. A new file that no
407
457
 
408
458
  `brief` compares the text of detected evidence, not line numbers, so moving code does not raise a finding. A change elsewhere in a manifest does not raise one either. A rename does count as a removal plus an addition.
409
459
 
460
+ `fit` estimates tokens at about four characters per token. That is not a model's tokenizer. A finding that cites a long provision costs those characters even when another finding cites the same text.
461
+
410
462
  ## Privacy
411
463
 
412
464
  Scanning reads the local tree and, for `brief`, local git history including `git blame`. There is no telemetry, no account, and no network call. The GitHub Action is the one place anything is sent anywhere, and it posts to your own repository with your own token. Use `--no-blame` to keep committer names out of a report.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flyleaf"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Local inventory of AI components in a repository. Not a legal risk classifier."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flyleaf"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Local inventory of AI components in a repository. Not a legal risk classifier."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -2,7 +2,7 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  """Inventory AI components in a repository and list areas to review."""
4
4
 
5
- __version__ = "0.5.0"
5
+ __version__ = "0.6.0"
6
6
 
7
7
  DISCLAIMER = (
8
8
  "flyleaf lists AI components and areas to review. "
@@ -11,6 +11,7 @@ from flyleaf import __version__
11
11
  from flyleaf.baseline import BASELINE_FILE, BaselineError, write_baseline
12
12
  from flyleaf.brief import GitError, build_brief, highest_severity
13
13
  from flyleaf.card import write_cards
14
+ from flyleaf.fit import FitError, benchmark_report, fit_document, render_fit
14
15
  from flyleaf.report import render, render_citations
15
16
  from flyleaf.scan import scan_path
16
17
  from flyleaf.severity import ORDER, at_least
@@ -129,6 +130,70 @@ def brief(
129
130
  raise typer.Exit(code=1)
130
131
 
131
132
 
133
+ @app.command()
134
+ def fit(
135
+ document: Path | None = typer.Argument(
136
+ None,
137
+ help="A brief, inventory, or memories file saved as json.",
138
+ ),
139
+ budget: int | None = typer.Option(
140
+ None,
141
+ "--budget",
142
+ "-b",
143
+ help="How many tokens the assistant may be given.",
144
+ ),
145
+ strategy: str = typer.Option(
146
+ "density",
147
+ "--strategy",
148
+ "-s",
149
+ help="density, greedy, or similarity.",
150
+ ),
151
+ query: str | None = typer.Option(
152
+ None,
153
+ "--query",
154
+ "-q",
155
+ help="What the assistant is about to answer. Similarity ranks on this.",
156
+ ),
157
+ order: str = typer.Option(
158
+ "density",
159
+ "--order",
160
+ help="Prompt order of the kept items: density, value, or input.",
161
+ ),
162
+ benchmark: bool = typer.Option(
163
+ False,
164
+ "--benchmark",
165
+ help="Print the packing scenarios instead of fitting a file.",
166
+ ),
167
+ output_format: str = typer.Option("markdown", "--format", "-f", help="markdown or json."),
168
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write the fit to this file."),
169
+ ) -> None:
170
+ """Choose the findings that fit a token budget, and say why the rest did not.
171
+
172
+ Density maximises total worth. Greedy takes the best worth per token and
173
+ moves on. Similarity takes the most relevant item first until the budget
174
+ is gone. On a brief, worth is engineering attention, not legal risk.
175
+ """
176
+ if benchmark:
177
+ _emit(benchmark_report(), output)
178
+ return
179
+ if document is None or budget is None:
180
+ typer.echo("Give a document and --budget, or pass --benchmark.", err=True)
181
+ raise typer.Exit(code=2)
182
+ _check_format(output_format, {"json", "markdown"})
183
+ try:
184
+ loaded = json.loads(document.read_text(encoding="utf-8"))
185
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
186
+ typer.echo(f"Could not read {document}: {exc}", err=True)
187
+ raise typer.Exit(code=2) from None
188
+ try:
189
+ fitted = fit_document(loaded, budget, strategy=strategy, query=query, order=order)
190
+ text = render_fit(fitted, output_format)
191
+ except FitError as exc:
192
+ typer.echo(str(exc), err=True)
193
+ raise typer.Exit(code=2) from None
194
+ _emit(text, output)
195
+
196
+
132
197
  @app.command(name="render")
133
198
  def render_command(
134
199
  document: Path = typer.Argument(..., help="A brief or inventory saved with --format json."),
@@ -0,0 +1,696 @@
1
+ # Copyright 2026 Krishna Dahale
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Choose which findings fit in a token budget, and say why the rest did not.
4
+
5
+ flyleaf already knows what changed. An assistant still cannot hold the whole
6
+ brief. ``fit`` is the choosing step: worth against tokens, with a reason for
7
+ every item that was kept or left out. It does not store the findings.
8
+ """
9
+
10
+ import json
11
+ import random
12
+ import re
13
+ from collections.abc import Callable, Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from flyleaf import DISCLAIMER, __version__
18
+ from flyleaf.pack import Candidate, Pack, density_pack, greedy_pack, similarity_pack
19
+
20
+ # Worth of a finding. This is engineering attention, the same scale as severity.
21
+ # It is not a legal risk tier.
22
+ ATTENTION = {"high": 3.0, "medium": 2.0, "low": 1.0}
23
+ ATTENTION_NOTE = (
24
+ "Worth on a brief or inventory is engineering attention, not legal importance. "
25
+ "High is 3, medium is 2, low is 1. A missing model card is 3, and a present card is 1. "
26
+ "Density maximises that worth. Similarity ranks by the query when you pass one, "
27
+ "and by this same worth otherwise."
28
+ )
29
+
30
+ TokenCounter = Callable[[str], int]
31
+ Strategy = Callable[[list[Candidate], int], Pack]
32
+
33
+
34
+ class FitError(ValueError):
35
+ """The caller asked for a fit flyleaf cannot do."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Memory:
40
+ """One candidate. flyleaf does not keep it.
41
+
42
+ ``value`` is worth. ``relevance`` is similarity to the current question,
43
+ and the similarity strategy ranks on it. When ``value`` is omitted, worth
44
+ falls back to ``relevance``, then to 1. With no scores at all, shorter
45
+ items win, because each one is worth the same and costs fewer tokens.
46
+ """
47
+
48
+ id: str
49
+ text: str
50
+ value: float | None = None
51
+ relevance: float | None = None
52
+ tokens: int | None = None
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Decision:
57
+ id: str
58
+ text: str
59
+ tokens: int
60
+ value: float
61
+ relevance: float | None
62
+ density: float
63
+ included: bool
64
+ code: str
65
+ reason: str
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class FitResult:
70
+ strategy: str
71
+ method: str
72
+ budget: int
73
+ tokens_used: int
74
+ value_captured: float
75
+ included: tuple[Decision, ...]
76
+ dropped: tuple[Decision, ...]
77
+
78
+ @property
79
+ def relevance_captured(self) -> float:
80
+ return sum(item.relevance or 0.0 for item in self.included)
81
+
82
+ def prompt(self, separator: str = "\n\n") -> str:
83
+ """The kept texts, in prompt order, ready for the context window."""
84
+ return separator.join(item.text for item in self.included)
85
+
86
+
87
+ _STRATEGIES: dict[str, Strategy] = {
88
+ "density": density_pack,
89
+ "greedy": greedy_pack,
90
+ "similarity": similarity_pack,
91
+ }
92
+
93
+
94
+ def register(name: str) -> Callable[[Strategy], Strategy]:
95
+ """Register a packer other people can pass to ``fit(..., strategy=name)``.
96
+
97
+ The callable receives resolved candidates and a budget, and returns a
98
+ ``Pack`` that names a code and a reason for every candidate and stays
99
+ inside the budget.
100
+ """
101
+ if not isinstance(name, str) or not name:
102
+ raise FitError("strategy name must be a non-empty string")
103
+
104
+ def decorator(fn: Strategy) -> Strategy:
105
+ if name in _STRATEGIES:
106
+ raise FitError(f"strategy already registered: {name}")
107
+ _STRATEGIES[name] = fn
108
+ return fn
109
+
110
+ return decorator
111
+
112
+
113
+ def fit(
114
+ memories: Sequence[Memory | Mapping[str, Any]],
115
+ budget: int,
116
+ *,
117
+ strategy: str = "density",
118
+ token_counter: TokenCounter | None = None,
119
+ order: str = "density",
120
+ ) -> FitResult:
121
+ """Choose the items that fit in ``budget`` tokens, and explain the rest.
122
+
123
+ ``order`` controls prompt order of the kept items: ``density`` (worth per
124
+ token), ``value``, or ``input``.
125
+ """
126
+ if isinstance(budget, bool) or not isinstance(budget, int) or budget < 0:
127
+ raise FitError("budget must be a non-negative integer")
128
+ if order not in {"density", "value", "input"}:
129
+ raise FitError("order must be 'density', 'value', or 'input'")
130
+ try:
131
+ packer = _STRATEGIES[strategy]
132
+ except KeyError:
133
+ known = ", ".join(sorted(_STRATEGIES))
134
+ raise FitError(f"unknown strategy {strategy!r}. Known strategies: {known}") from None
135
+
136
+ counter = token_counter or estimate_tokens
137
+ normalized = _normalize(memories)
138
+ candidates = [_candidate(index, memory, counter) for index, memory in enumerate(normalized)]
139
+ pack = packer(candidates, budget)
140
+ _check_pack(pack, candidates, budget)
141
+
142
+ by_index = {candidate.index: candidate for candidate in candidates}
143
+ included = tuple(
144
+ _decision(by_index[index], pack, included=True) for index in _included_order(candidates, pack, order)
145
+ )
146
+ dropped = tuple(
147
+ _decision(candidate, pack, included=False)
148
+ for candidate in _dropped_order(candidates, pack, strategy)
149
+ )
150
+ return FitResult(
151
+ strategy=strategy,
152
+ method=pack.method,
153
+ budget=budget,
154
+ tokens_used=sum(item.tokens for item in included),
155
+ value_captured=sum(item.value for item in included),
156
+ included=included,
157
+ dropped=dropped,
158
+ )
159
+
160
+
161
+ def fit_document(
162
+ document: dict | list,
163
+ budget: int,
164
+ *,
165
+ strategy: str = "density",
166
+ query: str | None = None,
167
+ order: str = "density",
168
+ ) -> dict:
169
+ """Fit a saved brief, inventory, or list of memories.
170
+
171
+ A brief packs active findings. Waived findings stay out of the suitcase.
172
+ An inventory packs components. A memories document packs the scores it
173
+ already carries. Worth for a brief or inventory is engineering attention.
174
+ """
175
+ memories, source_kind, waived = _memories_from(document, query)
176
+ chosen = fit(memories, budget, strategy=strategy, order=order)
177
+ others = {
178
+ name: fit(memories, budget, strategy=name, order=order)
179
+ for name in ("density", "greedy", "similarity")
180
+ if name != strategy
181
+ }
182
+ return {
183
+ "kind": "fit",
184
+ "tool": {"name": "flyleaf", "version": __version__},
185
+ "disclaimer": DISCLAIMER,
186
+ "value_note": ATTENTION_NOTE if source_kind in {"brief", "inventory"} else None,
187
+ "source_kind": source_kind,
188
+ "query": query or None,
189
+ "strategy": chosen.strategy,
190
+ "method": chosen.method,
191
+ "how": _how(chosen),
192
+ "budget": chosen.budget,
193
+ "tokens_used": chosen.tokens_used,
194
+ "value": chosen.value_captured,
195
+ "order": order,
196
+ "waived_not_packed": waived,
197
+ "compared": {
198
+ name: {"value": result.value_captured, "tokens_used": result.tokens_used, "method": result.method}
199
+ for name, result in others.items()
200
+ },
201
+ "included": [_decision_payload(item) for item in chosen.included],
202
+ "dropped": [_decision_payload(item) for item in chosen.dropped],
203
+ "prompt": chosen.prompt(),
204
+ }
205
+
206
+
207
+ def render_fit(document: dict, output_format: str) -> str:
208
+ if output_format == "json":
209
+ return json.dumps(document, indent=2) + "\n"
210
+ if output_format != "markdown":
211
+ raise FitError(f"unknown format: {output_format}")
212
+ return _markdown(document)
213
+
214
+
215
+ def estimate_tokens(text: str) -> int:
216
+ """About four characters per token, and never fewer than one."""
217
+ if not text:
218
+ return 1
219
+ return max(1, (len(text) + 3) // 4)
220
+
221
+
222
+ def benchmark_report() -> str:
223
+ """The suite that shows density is ahead of, or tied with, packing from the top."""
224
+ named = (short_gems(), awkward_pack(), equal_length())
225
+ random_rows = random_scenarios()
226
+ lines = [
227
+ "Worth captured at the same budget. Density maximises worth.",
228
+ "Similarity packs from the highest relevance down.",
229
+ "",
230
+ f"{'scenario':<16}{'budget':>8}{'density':>10}{'greedy':>10}{'similarity':>12}",
231
+ ]
232
+ scores = []
233
+ for scenario in named:
234
+ row = _score(scenario)
235
+ scores.append(row)
236
+ lines.append(
237
+ f"{row['name']:<16}{row['budget']:>8}"
238
+ f"{row['density']:>10.2f}{row['greedy']:>10.2f}{row['similarity']:>12.2f}"
239
+ )
240
+ random_scores = [_score(scenario) for scenario in random_rows]
241
+ scores.extend(random_scores)
242
+ lines.append(
243
+ f"{'random mean':<16}{80:>8}"
244
+ f"{_mean(row['density'] for row in random_scores):>10.2f}"
245
+ f"{_mean(row['greedy'] for row in random_scores):>10.2f}"
246
+ f"{_mean(row['similarity'] for row in random_scores):>12.2f}"
247
+ )
248
+ lines.append("")
249
+ lines.append("Relevance captured (what similarity is trying to maximise)")
250
+ for row in scores[:3]:
251
+ lines.append(
252
+ f" {row['name']:<14} density {row['density_relevance']:.2f}"
253
+ f" similarity {row['similarity_relevance']:.2f}"
254
+ )
255
+ wins, ties, losses = _tally(scores)
256
+ lines.append("")
257
+ lines.append(f"On worth, density finished ahead in {wins}, tied {ties}, and behind in {losses}.")
258
+ lines.append("")
259
+ return "\n".join(lines)
260
+
261
+
262
+ def short_gems() -> tuple[str, int, tuple[Memory, ...]]:
263
+ """Short, useful items against one long item that merely looks relevant."""
264
+ gems = tuple(
265
+ Memory(
266
+ id=f"gem-{number}",
267
+ text=f"Short fact {number} worth keeping.",
268
+ tokens=25,
269
+ value=0.80,
270
+ relevance=0.20,
271
+ )
272
+ for number in range(1, 5)
273
+ )
274
+ essay = Memory(
275
+ id="essay",
276
+ text="A long, mildly useful item that merely looks similar.",
277
+ tokens=90,
278
+ value=0.45,
279
+ relevance=0.98,
280
+ )
281
+ return "short gems", 100, (*gems, essay)
282
+
283
+
284
+ def awkward_pack() -> tuple[str, int, tuple[Memory, ...]]:
285
+ """The best rate blocks a pair that is worth more together."""
286
+ return (
287
+ "awkward pack",
288
+ 10,
289
+ (
290
+ Memory(id="dense", text="One dense item.", tokens=6, value=6, relevance=0.99),
291
+ Memory(id="pair-a", text="First of a better pair.", tokens=5, value=4, relevance=0.40),
292
+ Memory(id="pair-b", text="Second of a better pair.", tokens=5, value=4, relevance=0.40),
293
+ ),
294
+ )
295
+
296
+
297
+ def equal_length() -> tuple[str, int, tuple[Memory, ...]]:
298
+ """Same size, and worth matches relevance, so every strategy agrees."""
299
+ return (
300
+ "equal length",
301
+ 20,
302
+ (
303
+ Memory(id="strong", text="Clearly useful.", tokens=10, value=0.9, relevance=0.9),
304
+ Memory(id="middle", text="Somewhat useful.", tokens=10, value=0.5, relevance=0.5),
305
+ Memory(id="weak", text="Barely useful.", tokens=10, value=0.1, relevance=0.1),
306
+ ),
307
+ )
308
+
309
+
310
+ def random_scenarios(count: int = 20, seed: int = 0) -> tuple[tuple[str, int, tuple[Memory, ...]], ...]:
311
+ """Worth and relevance diverge, and lengths vary. Each pile still solves exactly."""
312
+ scenarios = []
313
+ for offset in range(count):
314
+ rng = random.Random(seed + offset)
315
+ memories = []
316
+ for index in range(12):
317
+ tokens = rng.choice((8, 16, 24, 40))
318
+ relevance = round(rng.uniform(0.2, 1.0), 2)
319
+ value = round(relevance * rng.uniform(0.25, 1.0), 2)
320
+ memories.append(
321
+ Memory(
322
+ id=f"m{index}",
323
+ text="A remembered fact.",
324
+ tokens=tokens,
325
+ value=value,
326
+ relevance=relevance,
327
+ )
328
+ )
329
+ scenarios.append((f"random-{offset}", 80, tuple(memories)))
330
+ return tuple(scenarios)
331
+
332
+
333
+ def _score(scenario: tuple[str, int, tuple[Memory, ...]]) -> dict:
334
+ name, budget, memories = scenario
335
+ density = fit(memories, budget, strategy="density")
336
+ greedy = fit(memories, budget, strategy="greedy")
337
+ similarity = fit(memories, budget, strategy="similarity")
338
+ for result in (density, greedy, similarity):
339
+ if result.tokens_used > budget:
340
+ raise FitError(f"{result.strategy} used {result.tokens_used} tokens over {budget}")
341
+ return {
342
+ "name": name,
343
+ "budget": budget,
344
+ "density": density.value_captured,
345
+ "greedy": greedy.value_captured,
346
+ "similarity": similarity.value_captured,
347
+ "density_relevance": density.relevance_captured,
348
+ "similarity_relevance": similarity.relevance_captured,
349
+ }
350
+
351
+
352
+ def _tally(rows: list[dict]) -> tuple[int, int, int]:
353
+ wins = ties = losses = 0
354
+ for row in rows:
355
+ if row["density"] > row["similarity"] + 1e-9:
356
+ wins += 1
357
+ elif row["density"] < row["similarity"] - 1e-9:
358
+ losses += 1
359
+ else:
360
+ ties += 1
361
+ return wins, ties, losses
362
+
363
+
364
+ def _mean(values) -> float:
365
+ listed = list(values)
366
+ return sum(listed) / len(listed)
367
+
368
+
369
+ def _memories_from(document: dict | list, query: str | None) -> tuple[list[Memory], str, int]:
370
+ if isinstance(document, list):
371
+ return _normalize(document), "memories", 0
372
+ if not isinstance(document, dict):
373
+ raise FitError("fit needs a brief, an inventory, or a list of memories")
374
+ kind = document.get("kind")
375
+ if kind == "memories":
376
+ raw = document.get("memories")
377
+ if not isinstance(raw, list):
378
+ raise FitError("a memories document needs a memories list")
379
+ return [_with_query(_coerce(item), query) for item in raw], "memories", 0
380
+ if kind == "brief":
381
+ return _from_brief(document, query), "brief", len(document.get("waived") or [])
382
+ if kind == "inventory":
383
+ return _from_inventory(document, query), "inventory", 0
384
+ raise FitError("fit needs a brief, an inventory, or a memories document")
385
+
386
+
387
+ def _with_query(memory: Memory, query: str | None) -> Memory:
388
+ if memory.relevance is not None or not query:
389
+ return memory
390
+ return Memory(
391
+ id=memory.id,
392
+ text=memory.text,
393
+ value=memory.value,
394
+ relevance=_query_relevance(memory.text, query),
395
+ tokens=memory.tokens,
396
+ )
397
+
398
+
399
+ def _from_brief(brief: dict, query: str | None) -> list[Memory]:
400
+ citations = brief.get("citations") or {}
401
+ memories = []
402
+ for finding in brief.get("findings") or []:
403
+ text = _attach_citations(_finding_text(finding), finding.get("citation_ids") or [], citations)
404
+ memories.append(
405
+ Memory(
406
+ id=str(finding.get("component_id") or finding.get("path") or "finding"),
407
+ text=text,
408
+ value=ATTENTION.get(finding.get("severity"), 1.0),
409
+ relevance=_query_relevance(text, query),
410
+ )
411
+ )
412
+ return memories
413
+
414
+
415
+ def _from_inventory(inventory: dict, query: str | None) -> list[Memory]:
416
+ citations = inventory.get("citations") or {}
417
+ memories = []
418
+ for component in inventory.get("components") or []:
419
+ text = _component_text(component, citations)
420
+ missing = component.get("model_card_status") == "missing"
421
+ memories.append(
422
+ Memory(
423
+ id=str(component.get("id") or component.get("path") or "component"),
424
+ text=text,
425
+ value=3.0 if missing else 1.0,
426
+ relevance=_query_relevance(text, query),
427
+ )
428
+ )
429
+ return memories
430
+
431
+
432
+ def _finding_text(finding: dict) -> str:
433
+ if finding.get("kind") == "system":
434
+ subject = f"system {finding.get('system')} ({finding.get('framework')})"
435
+ else:
436
+ subject = f"{finding.get('path')} ({finding.get('framework')})"
437
+ lines = [
438
+ subject,
439
+ f"severity {finding.get('severity')}, {finding.get('change')}, {finding.get('status')}",
440
+ str(finding.get("summary") or ""),
441
+ ]
442
+ for member in finding.get("members") or []:
443
+ lines.append(f"{member.get('path')} ({member.get('framework')}) {member.get('change')}")
444
+ return "\n".join(lines)
445
+
446
+
447
+ def _component_text(component: dict, citations: dict) -> str:
448
+ lines = [
449
+ f"{component.get('path')} ({component.get('framework')})",
450
+ f"role {component.get('role_hint') or ''}".rstrip(),
451
+ f"model card {component.get('model_card_status') or ''}".rstrip(),
452
+ ]
453
+ for item in component.get("evidence") or []:
454
+ lines.append(f"line {item.get('line')}: {item.get('text')}")
455
+ ids: list[str] = []
456
+ for hint in component.get("review_hints") or []:
457
+ if hint.get("text"):
458
+ lines.append(hint["text"])
459
+ ids.extend(hint.get("citation_ids") or [])
460
+ ids.extend(component.get("documentation_citation_ids") or [])
461
+ return _attach_citations("\n".join(lines), ids, citations)
462
+
463
+
464
+ def _attach_citations(text: str, citation_ids: list, citations: dict) -> str:
465
+ blocks = [text]
466
+ seen: set[str] = set()
467
+ for citation_id in citation_ids:
468
+ if citation_id in seen:
469
+ continue
470
+ seen.add(citation_id)
471
+ payload = citations.get(citation_id) if isinstance(citations, dict) else None
472
+ if not isinstance(payload, dict):
473
+ continue
474
+ pinpoint = payload.get("pinpoint") or citation_id
475
+ quote = payload.get("quote") or ""
476
+ blocks.append(f"{pinpoint}: {quote}".rstrip())
477
+ return "\n".join(blocks)
478
+
479
+
480
+ def _query_relevance(text: str, query: str | None) -> float | None:
481
+ """Share of query words that appear in the text. Length does not raise it."""
482
+ if not query or not query.strip():
483
+ return None
484
+ terms = [term for term in re.findall(r"[a-z0-9]+", query.lower()) if len(term) > 1]
485
+ if not terms:
486
+ return None
487
+ haystack = text.lower()
488
+ hits = sum(1 for term in terms if term in haystack)
489
+ return hits / len(terms)
490
+
491
+
492
+ def _decision_payload(item: Decision) -> dict:
493
+ return {
494
+ "id": item.id,
495
+ "text": item.text,
496
+ "tokens": item.tokens,
497
+ "value": item.value,
498
+ "relevance": item.relevance,
499
+ "density": item.density,
500
+ "code": item.code,
501
+ "reason": item.reason,
502
+ }
503
+
504
+
505
+ def _markdown(document: dict) -> str:
506
+ lines = ["# flyleaf fit", "", DISCLAIMER, ""]
507
+ if document.get("value_note"):
508
+ lines.extend([document["value_note"], ""])
509
+ lines.append(document["how"])
510
+ lines.append(
511
+ f"Budget {document['budget']} used {document['tokens_used']} value {document['value']:.2f}"
512
+ )
513
+ if document.get("query"):
514
+ lines.append(f"Query: {document['query']}")
515
+ compared = document.get("compared") or {}
516
+ if compared:
517
+ bits = [f"{name} {row['value']:.2f}" for name, row in compared.items()]
518
+ lines.append("On this same pile: " + ", ".join(bits) + ".")
519
+ waived = document.get("waived_not_packed") or 0
520
+ if waived:
521
+ lines.append(f"Waived findings left unpacked: {waived}.")
522
+ lines.append("")
523
+ lines.extend(_section("Included", document["included"]))
524
+ lines.append("")
525
+ lines.extend(_section("Left out", document["dropped"]))
526
+ lines.append("")
527
+ lines.append("## Hand to the assistant")
528
+ lines.append("")
529
+ prompt = document.get("prompt") or ""
530
+ lines.append(prompt if prompt else "(nothing)")
531
+ lines.append("")
532
+ return "\n".join(lines)
533
+
534
+
535
+ def _section(title: str, decisions: list[dict]) -> list[str]:
536
+ lines = [f"## {title} ({len(decisions)})", ""]
537
+ if not decisions:
538
+ lines.append("(none)")
539
+ return lines
540
+ for item in decisions:
541
+ relevance = ""
542
+ if item.get("relevance") is not None:
543
+ relevance = f", relevance {item['relevance']:.2f}"
544
+ lines.append(
545
+ f"- `{item['id']}` — {item['tokens']} tok, value {item['value']:.2f}, "
546
+ f"{item['density']:.4f} per token{relevance}"
547
+ )
548
+ lines.append(f" {item['reason']}")
549
+ return lines
550
+
551
+
552
+ def _how(result: FitResult) -> str:
553
+ if result.strategy == "density" and result.method == "exact":
554
+ return "density, solved exactly"
555
+ if result.strategy == "density":
556
+ return "density, packed by value per token"
557
+ if result.strategy == "similarity":
558
+ return "similarity, packed from the top"
559
+ if result.strategy == "greedy":
560
+ return "greedy, packed by value per token"
561
+ return result.strategy
562
+
563
+
564
+ def _included_order(candidates: list[Candidate], pack: Pack, order: str) -> list[int]:
565
+ chosen = [candidate for candidate in candidates if candidate.index in pack.chosen]
566
+
567
+ def key(candidate: Candidate) -> tuple:
568
+ if order == "value":
569
+ return (-candidate.value, -candidate.density, candidate.index)
570
+ if order == "input":
571
+ return (candidate.index,)
572
+ return (-candidate.density, -candidate.value, candidate.index)
573
+
574
+ chosen.sort(key=key)
575
+ return [candidate.index for candidate in chosen]
576
+
577
+
578
+ def _dropped_order(candidates: list[Candidate], pack: Pack, strategy: str) -> list[Candidate]:
579
+ dropped = [candidate for candidate in candidates if candidate.index not in pack.chosen]
580
+ if strategy == "similarity":
581
+ dropped.sort(key=lambda candidate: (-_rank_relevance(candidate), candidate.index))
582
+ else:
583
+ dropped.sort(key=lambda candidate: (-candidate.density, -candidate.value, candidate.index))
584
+ return dropped
585
+
586
+
587
+ def _rank_relevance(candidate: Candidate) -> float:
588
+ if candidate.relevance is not None:
589
+ return candidate.relevance
590
+ return candidate.value
591
+
592
+
593
+ def _decision(candidate: Candidate, pack: Pack, *, included: bool) -> Decision:
594
+ return Decision(
595
+ id=candidate.id,
596
+ text=candidate.text,
597
+ tokens=candidate.tokens,
598
+ value=candidate.value,
599
+ relevance=candidate.relevance,
600
+ density=candidate.density,
601
+ included=included,
602
+ code=pack.codes[candidate.index],
603
+ reason=pack.reasons[candidate.index],
604
+ )
605
+
606
+
607
+ def _check_pack(pack: Pack, candidates: list[Candidate], budget: int) -> None:
608
+ expected = set(range(len(candidates)))
609
+ if set(pack.codes) != expected or set(pack.reasons) != expected:
610
+ raise FitError("strategy must explain every candidate")
611
+ if any(not pack.reasons[index].strip() for index in expected):
612
+ raise FitError("strategy returned an empty reason")
613
+ chosen_tokens = 0
614
+ for index in expected:
615
+ selected = index in pack.chosen
616
+ if selected != (pack.codes[index] == "selected"):
617
+ raise FitError("strategy codes do not match the chosen set")
618
+ if selected:
619
+ chosen_tokens += candidates[index].tokens
620
+ if chosen_tokens > budget:
621
+ raise FitError(f"strategy used {chosen_tokens} tokens, which is over the budget of {budget}")
622
+
623
+
624
+ def _normalize(memories: Sequence[Memory | Mapping[str, Any]]) -> list[Memory]:
625
+ normalized: list[Memory] = []
626
+ seen: set[str] = set()
627
+ for raw in memories:
628
+ memory = _coerce(raw)
629
+ if not isinstance(memory.id, str) or not memory.id:
630
+ raise FitError("every item needs a non-empty string id")
631
+ if not isinstance(memory.text, str):
632
+ raise FitError(f"{memory.id}: text must be a string")
633
+ if memory.id in seen:
634
+ raise FitError(f"duplicate id: {memory.id}")
635
+ seen.add(memory.id)
636
+ normalized.append(memory)
637
+ return normalized
638
+
639
+
640
+ def _coerce(raw: Memory | Mapping[str, Any]) -> Memory:
641
+ if isinstance(raw, Memory):
642
+ return raw
643
+ if isinstance(raw, Mapping):
644
+ try:
645
+ ident = raw["id"]
646
+ text = raw["text"]
647
+ except KeyError as exc:
648
+ raise FitError("memory mappings need 'id' and 'text'") from exc
649
+ return Memory(
650
+ id=ident,
651
+ text=text,
652
+ value=raw.get("value"),
653
+ relevance=raw.get("relevance"),
654
+ tokens=raw.get("tokens"),
655
+ )
656
+ raise FitError("memories must be items with an id and text")
657
+
658
+
659
+ def _candidate(index: int, memory: Memory, counter: TokenCounter) -> Candidate:
660
+ relevance = None if memory.relevance is None else _number(memory.relevance, memory.id, "relevance")
661
+ return Candidate(
662
+ index=index,
663
+ id=memory.id,
664
+ text=memory.text,
665
+ tokens=_resolve_tokens(memory, counter),
666
+ value=_resolve_value(memory),
667
+ relevance=relevance,
668
+ )
669
+
670
+
671
+ def _resolve_value(memory: Memory) -> float:
672
+ if memory.value is not None:
673
+ return _number(memory.value, memory.id, "value")
674
+ if memory.relevance is not None:
675
+ return _number(memory.relevance, memory.id, "relevance")
676
+ return 1.0
677
+
678
+
679
+ def _resolve_tokens(memory: Memory, counter: TokenCounter) -> int:
680
+ if memory.tokens is None:
681
+ counted = counter(memory.text)
682
+ if isinstance(counted, bool) or not isinstance(counted, int) or counted < 1:
683
+ raise FitError(f"{memory.id}: token_counter must return a positive integer")
684
+ return counted
685
+ if isinstance(memory.tokens, bool) or not isinstance(memory.tokens, int) or memory.tokens < 1:
686
+ raise FitError(f"{memory.id}: tokens must be a positive integer")
687
+ return memory.tokens
688
+
689
+
690
+ def _number(value: object, ident: str, field: str) -> float:
691
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
692
+ raise FitError(f"{ident}: {field} must be a number")
693
+ number = float(value)
694
+ if number != number or number in {float("inf"), float("-inf")}:
695
+ raise FitError(f"{ident}: {field} must be a finite number")
696
+ return number
@@ -0,0 +1,275 @@
1
+ # Copyright 2026 Krishna Dahale
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Pack a token budget, and keep a reason for every candidate.
4
+
5
+ ``density`` maximises total worth. It solves the 0/1 knapsack exactly
6
+ while the instance is small enough, and falls back to packing by worth
7
+ per token after that. ``similarity`` is the usual baseline: rank by
8
+ relevance, then take from the top until the budget is gone. ``greedy``
9
+ is that same pack, ordered by worth per token instead of relevance.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+
14
+ # n * (budget + 1) cells. Past this, exact search gives way to greedy.
15
+ EXACT_CELL_LIMIT = 250_000
16
+ # Extra exact solves used only to explain drops. Past this, the reason
17
+ # names the trade without re-solving.
18
+ FORCE_IN_CELL_LIMIT = 2_000_000
19
+ _EPS = 1e-9
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Candidate:
24
+ """One item after worth and token cost are known."""
25
+
26
+ index: int
27
+ id: str
28
+ text: str
29
+ tokens: int
30
+ value: float
31
+ relevance: float | None
32
+
33
+ @property
34
+ def density(self) -> float:
35
+ return self.value / self.tokens
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Pack:
40
+ chosen: frozenset[int]
41
+ codes: dict[int, str]
42
+ reasons: dict[int, str]
43
+ method: str
44
+
45
+
46
+ def density_pack(candidates: list[Candidate], budget: int) -> Pack:
47
+ if _over_exact_limit(candidates, budget):
48
+ return _rank_pack(
49
+ candidates,
50
+ budget,
51
+ score_of=lambda item: item.density,
52
+ included_by="value per token",
53
+ higher_than="higher value-per-token items",
54
+ skip_non_positive=True,
55
+ method="greedy",
56
+ )
57
+ chosen = _exact_indices(candidates, budget)
58
+ codes, reasons = _explain_exact(candidates, chosen, budget)
59
+ return Pack(frozenset(chosen), codes, reasons, "exact")
60
+
61
+
62
+ def greedy_pack(candidates: list[Candidate], budget: int) -> Pack:
63
+ return _rank_pack(
64
+ candidates,
65
+ budget,
66
+ score_of=lambda item: item.density,
67
+ included_by="value per token",
68
+ higher_than="higher value-per-token items",
69
+ skip_non_positive=True,
70
+ method="greedy",
71
+ )
72
+
73
+
74
+ def similarity_pack(candidates: list[Candidate], budget: int) -> Pack:
75
+ return _rank_pack(
76
+ candidates,
77
+ budget,
78
+ score_of=_similarity,
79
+ included_by="similarity rank",
80
+ higher_than="higher-similarity items",
81
+ skip_non_positive=False,
82
+ method="rank",
83
+ )
84
+
85
+
86
+ def _similarity(item: Candidate) -> float:
87
+ if item.relevance is not None:
88
+ return item.relevance
89
+ return item.value
90
+
91
+
92
+ def _over_exact_limit(candidates: list[Candidate], budget: int) -> bool:
93
+ return len(candidates) * (budget + 1) > EXACT_CELL_LIMIT
94
+
95
+
96
+ def _exact_indices(candidates: list[Candidate], budget: int) -> list[int]:
97
+ """Indices of the maximum-value subset. Ties break toward fewer tokens."""
98
+ if budget <= 0:
99
+ return []
100
+ items = [item for item in candidates if item.value > 0 and item.tokens <= budget]
101
+ count = len(items)
102
+ if count == 0:
103
+ return []
104
+
105
+ value = [0.0] * (budget + 1)
106
+ used = [0] * (budget + 1)
107
+ took = [bytearray(budget + 1) for _ in range(count)]
108
+
109
+ for index, item in enumerate(items):
110
+ weight = item.tokens
111
+ gain = item.value
112
+ row = took[index]
113
+ for capacity in range(budget, weight - 1, -1):
114
+ candidate_value = value[capacity - weight] + gain
115
+ candidate_used = used[capacity - weight] + weight
116
+ better_value = candidate_value > value[capacity] + _EPS
117
+ same_value = abs(candidate_value - value[capacity]) <= _EPS
118
+ if better_value or (same_value and candidate_used < used[capacity]):
119
+ value[capacity] = candidate_value
120
+ used[capacity] = candidate_used
121
+ row[capacity] = 1
122
+
123
+ best_capacity = 0
124
+ for capacity in range(1, budget + 1):
125
+ better_value = value[capacity] > value[best_capacity] + _EPS
126
+ same_value = abs(value[capacity] - value[best_capacity]) <= _EPS
127
+ if better_value or (same_value and used[capacity] < used[best_capacity]):
128
+ best_capacity = capacity
129
+
130
+ chosen: list[int] = []
131
+ capacity = best_capacity
132
+ for index in range(count - 1, -1, -1):
133
+ if took[index][capacity]:
134
+ chosen.append(items[index].index)
135
+ capacity -= items[index].tokens
136
+ return chosen
137
+
138
+
139
+ def _explain_exact(
140
+ candidates: list[Candidate],
141
+ chosen: list[int],
142
+ budget: int,
143
+ ) -> tuple[dict[int, str], dict[int, str]]:
144
+ chosen_set = set(chosen)
145
+ chosen_items = [item for item in candidates if item.index in chosen_set]
146
+ best_value = sum(item.value for item in chosen_items)
147
+ best_tokens = sum(item.tokens for item in chosen_items)
148
+ codes: dict[int, str] = {}
149
+ reasons: dict[int, str] = {}
150
+ competitive: list[Candidate] = []
151
+
152
+ for item in candidates:
153
+ if item.index in chosen_set:
154
+ codes[item.index] = "selected"
155
+ reasons[item.index] = "In the highest-value set that fits."
156
+ elif budget <= 0:
157
+ codes[item.index] = "empty_budget"
158
+ reasons[item.index] = "The budget is 0 tokens."
159
+ elif item.value <= 0:
160
+ codes[item.index] = "non_positive"
161
+ reasons[item.index] = "Value is not positive, so it does not earn any tokens."
162
+ elif item.tokens > budget:
163
+ codes[item.index] = "exceeds_budget"
164
+ reasons[item.index] = f"Needs {item.tokens} tokens, more than the budget of {budget}."
165
+ else:
166
+ competitive.append(item)
167
+
168
+ use_force_in = len(competitive) * max(len(candidates), 1) * (budget + 1) <= FORCE_IN_CELL_LIMIT
169
+ for item in competitive:
170
+ codes[item.index] = "displaced"
171
+ if use_force_in:
172
+ reasons[item.index] = _force_in_reason(item, candidates, budget, best_value, best_tokens)
173
+ else:
174
+ reasons[item.index] = _displacement_reason(item, chosen_items, budget, best_value)
175
+ return codes, reasons
176
+
177
+
178
+ def _force_in_reason(
179
+ item: Candidate,
180
+ candidates: list[Candidate],
181
+ budget: int,
182
+ best_value: float,
183
+ best_tokens: int,
184
+ ) -> str:
185
+ others = [candidate for candidate in candidates if candidate.index != item.index]
186
+ rest = _exact_indices(others, budget - item.tokens)
187
+ rest_set = set(rest)
188
+ rest_items = [candidate for candidate in others if candidate.index in rest_set]
189
+ forced_value = item.value + sum(candidate.value for candidate in rest_items)
190
+ forced_tokens = item.tokens + sum(candidate.tokens for candidate in rest_items)
191
+ if forced_value < best_value - _EPS:
192
+ return f"Forcing this in drops total value from {best_value:.2f} to {forced_value:.2f}."
193
+ if forced_tokens > best_tokens:
194
+ return (
195
+ f"Forcing this in ties on value but uses {forced_tokens} tokens instead of {best_tokens}."
196
+ )
197
+ return "Left out of the highest-value set that fits."
198
+
199
+
200
+ def _displacement_reason(
201
+ item: Candidate,
202
+ chosen_items: list[Candidate],
203
+ budget: int,
204
+ best_value: float,
205
+ ) -> str:
206
+ """A concrete trade that is true even when we do not re-solve."""
207
+ room = budget - sum(candidate.tokens for candidate in chosen_items)
208
+ if item.tokens <= room:
209
+ return f"Left out of the highest-value set (worth {best_value:.2f})."
210
+ need = item.tokens - room
211
+ victims = sorted(
212
+ chosen_items,
213
+ key=lambda candidate: (candidate.density, candidate.value, candidate.index),
214
+ )
215
+ removed: list[Candidate] = []
216
+ freed = 0
217
+ for victim in victims:
218
+ removed.append(victim)
219
+ freed += victim.tokens
220
+ if freed >= need:
221
+ break
222
+ removed_value = sum(victim.value for victim in removed)
223
+ if removed and item.value <= removed_value + _EPS:
224
+ names = ", ".join(victim.id for victim in removed)
225
+ return (
226
+ f"Taking it means dropping {names} (worth {removed_value:.2f}) "
227
+ f"to free {freed} tokens, and this item is only worth {item.value:.2f}."
228
+ )
229
+ return f"Left out of the highest-value set (worth {best_value:.2f})."
230
+
231
+
232
+ def _rank_pack(
233
+ candidates: list[Candidate],
234
+ budget: int,
235
+ *,
236
+ score_of,
237
+ included_by: str,
238
+ higher_than: str,
239
+ skip_non_positive: bool,
240
+ method: str,
241
+ ) -> Pack:
242
+ codes: dict[int, str] = {}
243
+ reasons: dict[int, str] = {}
244
+ chosen: list[int] = []
245
+ remaining = budget
246
+ ordered = sorted(candidates, key=lambda item: (-score_of(item), item.index))
247
+
248
+ for item in ordered:
249
+ if budget <= 0:
250
+ codes[item.index] = "empty_budget"
251
+ reasons[item.index] = "The budget is 0 tokens."
252
+ continue
253
+ if skip_non_positive and item.value <= 0:
254
+ codes[item.index] = "non_positive"
255
+ reasons[item.index] = "Value is not positive, so it does not earn any tokens."
256
+ continue
257
+ if item.tokens > budget:
258
+ codes[item.index] = "exceeds_budget"
259
+ reasons[item.index] = f"Needs {item.tokens} tokens, more than the budget of {budget}."
260
+ continue
261
+ if item.tokens <= remaining:
262
+ open_before = remaining
263
+ remaining -= item.tokens
264
+ chosen.append(item.index)
265
+ codes[item.index] = "selected"
266
+ reasons[item.index] = (
267
+ f"Included by {included_by}, and {item.tokens} tokens fit in the {open_before} still open."
268
+ )
269
+ continue
270
+ codes[item.index] = "no_room"
271
+ reasons[item.index] = (
272
+ f"Needs {item.tokens} tokens and only {remaining} remained after {higher_than}."
273
+ )
274
+
275
+ return Pack(frozenset(chosen), codes, reasons, method)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes