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.
- {flyleaf-0.5.0 → flyleaf-0.6.0}/PKG-INFO +58 -6
- {flyleaf-0.5.0 → flyleaf-0.6.0}/README.md +57 -5
- {flyleaf-0.5.0 → flyleaf-0.6.0}/pyproject.toml +1 -1
- {flyleaf-0.5.0 → flyleaf-0.6.0}/pyproject.toml.orig +1 -1
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/__init__.py +1 -1
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/cli.py +65 -0
- flyleaf-0.6.0/src/flyleaf/fit.py +696 -0
- flyleaf-0.6.0/src/flyleaf/pack.py +275 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/LICENSE +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/attribution.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/baseline.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/brief.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/card.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/citations.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/report.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/rules.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/scan.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/severity.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/systems.py +0 -0
- {flyleaf-0.5.0 → flyleaf-0.6.0}/src/flyleaf/waivers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flyleaf
|
|
3
|
-
Version: 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
|
-
[](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/flyleaf/)
|
|
31
|
+
[](https://pypi.org/project/flyleaf/)
|
|
32
|
+
[](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.
|
|
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
|
-
[](https://github.com/krishyaid-coder/flyleaf/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/flyleaf/)
|
|
5
|
+
[](https://pypi.org/project/flyleaf/)
|
|
6
|
+
[](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.
|
|
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.
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|