claude-context-cost 1.3.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-context-cost
3
+ Version: 1.3.1
4
+ Summary: How much each source (CLAUDE.md, rules, skills, memory, tool results) costs in the context of a Claude Code session, counted from the API usage in its transcript.
5
+ Author: Peter
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/peterwkdev-creator/claude-context-cost
8
+ Project-URL: Issues, https://github.com/peterwkdev-creator/claude-context-cost/issues
9
+ Keywords: claude-code,context-window,tokens,transcript,cli
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # claude-context-cost
26
+
27
+ How much each source (your `CLAUDE.md`, rules, skills, memory, tool results)
28
+ costs in the context of a Claude Code session, counted from the API `usage`
29
+ recorded in its transcript, not estimated from characters.
30
+
31
+ ![The page counts the synthetic example, then a transcript file](docs/demo.gif)
32
+
33
+ **[Try it in the browser](https://peterwkdev-creator.github.io/claude-context-cost/)**:
34
+ choose one of your transcripts, or run the synthetic example. The library runs
35
+ in the page (Pyodide); your file is never uploaded.
36
+
37
+ The usual estimate, characters / 4, **undercounts by 40 to 45%**: −45% over
38
+ 5,365 steps of the author's sessions, mostly in Portuguese, and −40% in an
39
+ English-language session (2026-10-10). Here every response adds up to its
40
+ `usage` exactly, in all 457 transcripts tested.
41
+
42
+ > Reads only the transcripts under `~/.claude/projects` on your machine.
43
+ > Nothing is sent anywhere. The numbers are tokens, not dollars.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install claude-context-cost
49
+ ```
50
+
51
+ Python 3.11 or newer, no dependencies. To install a tagged version straight
52
+ from GitHub instead:
53
+
54
+ ```bash
55
+ pip install "git+https://github.com/peterwkdev-creator/claude-context-cost@v1.3.1"
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```bash
61
+ claude-context-cost --example
62
+ ```
63
+
64
+ ```bash
65
+ claude-context-cost ~/.claude/projects/<project-folder>/<session-id>.jsonl
66
+ ```
67
+
68
+ The newest `.jsonl` in a project's folder is its latest session (on Windows,
69
+ `~` is `%USERPROFILE%`). A subagent has its own transcript,
70
+ `<session-id>/subagents/agent-<id>.jsonl`.
71
+
72
+ Pass a project's folder instead of a file to sum its sessions and their
73
+ subagents: the costliest sources first, then one line per session. Pass
74
+ `~/.claude/projects` itself to sum every project. Tools and your own
75
+ `~/.claude/...` files add up across projects; a project's `CLAUDE.md` and
76
+ rules stay apart, marked with the project.
77
+
78
+ `--steps` shows what entered before each response, `--all` lists every
79
+ source, `--json` is for scripts (for a folder too). On the synthetic example
80
+ shipped with the package:
81
+
82
+ ```text
83
+ cost share entered in chars/4 error how source
84
+ 128,240 80.6% 36,640 7 - - - unattributed: system prompt, tools, overhead
85
+ 10,400 6.5% 3,200 4 2,000 -38% measured, split tool: Read
86
+ 5,600 3.5% 1,600 7 1,000 -38% estimated claude.md: CLAUDE.md
87
+ 1,600 1.0% 800 4 500 -38% split, estimated path rule: .claude/rules/python.md
88
+ ```
89
+
90
+ - **cost**: tokens × the responses the source stayed in context, until a
91
+ compaction removed it. This is what you pay for over the session.
92
+ - **entered**: the tokens it brought in; **in**: the responses it stayed for.
93
+ - **chars/4** and **error**: what the usual estimate would say, beside it.
94
+
95
+ From Python:
96
+
97
+ ```python
98
+ from claude_context_cost import analyze, example_path
99
+
100
+ report = analyze(example_path()) # or the path of any .jsonl transcript
101
+ print(report.total) # 159160: input tokens summed over the 7 responses
102
+ read = report.sources[("tool", "Read")]
103
+ print(read.entered, read.responses, read.cost) # 3200 4 10400
104
+ print(read.estimate_div4) # 2000.0: characters / 4 for the same text
105
+ ```
106
+
107
+ `summarize(folder)` returns the same sum as a folder on the command line:
108
+ `.sources` maps `(kind, name, project)` to the totals, with `project` set only
109
+ for a project's own files.
110
+
111
+ ## Why
112
+
113
+ [claude-devtools](https://github.com/matt1398/claude-devtools) already shows
114
+ the context by source. Read in its code at commit `1486f20` (the latest on
115
+ 2026-10-10):
116
+
117
+ - Each source's tokens are characters / 4 (`src/main/utils/tokenizer.ts`);
118
+ only the turn total comes from the `usage`.
119
+ - Rules in `.claude/rules/` are listed as global sources without reading
120
+ their `paths:`, so a rule that never loaded still counts. Issue
121
+ [#224](https://github.com/matt1398/claude-devtools/issues/224), open since
122
+ 2026-07-17.
123
+ - `CLAUDE.md` and rules are read from disk today, not as they were during the
124
+ session.
125
+
126
+ This tool fixes the first two from the transcript alone.
127
+
128
+ ## What it counts
129
+
130
+ - **One response per `message.id`.** A response is written over several
131
+ lines with the same `usage`; it counts once.
132
+ - **Context = `input_tokens` + `cache_read_input_tokens` +
133
+ `cache_creation_input_tokens`.**
134
+ - **What entered** between two responses is the context growth minus the
135
+ previous response's output. When a single source entered, its tokens are
136
+ **measured**. When several did, the growth is **split** by their size.
137
+ - **At the start of the session and after each compaction** the context
138
+ jumps at once: each source is **estimated** with the bytes-per-token ratio
139
+ measured in the same transcript (median of its text-only steps; 2.25 when
140
+ it has fewer than 3), and the rest is **unattributed** (system prompt,
141
+ tool definitions). So every response adds up to its `usage`.
142
+ - **Rules with `paths:`** count only from the response where Claude Code
143
+ loaded them (`nested_memory`), never before.
144
+ - **Only what Claude Code rendered** into the context counts; bookkeeping
145
+ lines in the transcript do not.
146
+ - **A compaction** removes everything; messages it keeps count as "kept by
147
+ compaction", not as new responses.
148
+
149
+ ## How it is tested
150
+
151
+ - A synthetic transcript with known values (`tests/fixtures/build_example.py`)
152
+ where every source's tokens are worked out by hand: each one comes out
153
+ exact, every response adds up to its `usage`, a `paths:` rule that never
154
+ loaded counts 0, a response written over several lines counts once.
155
+ - `python tests/mutate.py` breaks each of those rules in a copy of the code
156
+ (17 mutants); the tests catch all 17.
157
+ - On the author's machine (2026-10-10): 75 session transcripts and 382
158
+ subagent transcripts, 91,347 responses, 0 where the sources differ from the
159
+ `usage`, 0 where the response count differs from an independent count, and
160
+ the sum over every project takes every transcript. The
161
+ ratio was measured in 50 of the 73 sessions with responses, median 2.22
162
+ bytes per token. Run it on yours with
163
+ `CLAUDE_CONTEXT_COST_CORPUS=1 python -m unittest tests.test_corpus -v`
164
+ (it prints only counts).
165
+
166
+ ## Limits
167
+
168
+ - **Split and estimated tokens are not measured.** The `how` column says
169
+ which is which. Many steps bring a tool result together with a small
170
+ reminder, so splitting is common.
171
+ - **Output includes thinking.** Thinking may leave the context later, so
172
+ the previous output can be overcounted; the remainder then shows as
173
+ negative unattributed tokens (191 of 91,347 responses).
174
+ - **A session's total does not include its subagents**; the sum of its
175
+ project's folder does.
176
+ - **English was measured in one session** (2026-10-10): 8 file reads, a
177
+ license, release notes, two READMEs, three Python modules and a git log;
178
+ median −40%, from −29% to −47%. The other numbers come from one person's
179
+ sessions, mostly in Portuguese.
180
+ - The transcript format is not documented and changes between versions.
181
+
182
+ ## Compatibility
183
+
184
+ Transcripts of Claude Code 2.1 (the synthetic fixture follows 2.1.293).
185
+ Python 3.11 to 3.14, tested on Linux and Windows.
186
+
187
+ Versions follow [Semantic Versioning](https://semver.org/): the names in
188
+ `claude_context_cost.__all__` and the keys of `--json` change incompatibly only
189
+ in a new major version. The transcript format is Claude Code's and changes
190
+ without notice; when it does, a fix keeps the API and ships as a minor or
191
+ patch version.
192
+
193
+ ## Contributing
194
+
195
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Never paste a real transcript in an
196
+ issue or pull request: it holds paths, file contents and sometimes secrets.
197
+
198
+ ## License
199
+
200
+ [MIT](LICENSE)
@@ -0,0 +1,176 @@
1
+ # claude-context-cost
2
+
3
+ How much each source (your `CLAUDE.md`, rules, skills, memory, tool results)
4
+ costs in the context of a Claude Code session, counted from the API `usage`
5
+ recorded in its transcript, not estimated from characters.
6
+
7
+ ![The page counts the synthetic example, then a transcript file](docs/demo.gif)
8
+
9
+ **[Try it in the browser](https://peterwkdev-creator.github.io/claude-context-cost/)**:
10
+ choose one of your transcripts, or run the synthetic example. The library runs
11
+ in the page (Pyodide); your file is never uploaded.
12
+
13
+ The usual estimate, characters / 4, **undercounts by 40 to 45%**: −45% over
14
+ 5,365 steps of the author's sessions, mostly in Portuguese, and −40% in an
15
+ English-language session (2026-10-10). Here every response adds up to its
16
+ `usage` exactly, in all 457 transcripts tested.
17
+
18
+ > Reads only the transcripts under `~/.claude/projects` on your machine.
19
+ > Nothing is sent anywhere. The numbers are tokens, not dollars.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install claude-context-cost
25
+ ```
26
+
27
+ Python 3.11 or newer, no dependencies. To install a tagged version straight
28
+ from GitHub instead:
29
+
30
+ ```bash
31
+ pip install "git+https://github.com/peterwkdev-creator/claude-context-cost@v1.3.1"
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ claude-context-cost --example
38
+ ```
39
+
40
+ ```bash
41
+ claude-context-cost ~/.claude/projects/<project-folder>/<session-id>.jsonl
42
+ ```
43
+
44
+ The newest `.jsonl` in a project's folder is its latest session (on Windows,
45
+ `~` is `%USERPROFILE%`). A subagent has its own transcript,
46
+ `<session-id>/subagents/agent-<id>.jsonl`.
47
+
48
+ Pass a project's folder instead of a file to sum its sessions and their
49
+ subagents: the costliest sources first, then one line per session. Pass
50
+ `~/.claude/projects` itself to sum every project. Tools and your own
51
+ `~/.claude/...` files add up across projects; a project's `CLAUDE.md` and
52
+ rules stay apart, marked with the project.
53
+
54
+ `--steps` shows what entered before each response, `--all` lists every
55
+ source, `--json` is for scripts (for a folder too). On the synthetic example
56
+ shipped with the package:
57
+
58
+ ```text
59
+ cost share entered in chars/4 error how source
60
+ 128,240 80.6% 36,640 7 - - - unattributed: system prompt, tools, overhead
61
+ 10,400 6.5% 3,200 4 2,000 -38% measured, split tool: Read
62
+ 5,600 3.5% 1,600 7 1,000 -38% estimated claude.md: CLAUDE.md
63
+ 1,600 1.0% 800 4 500 -38% split, estimated path rule: .claude/rules/python.md
64
+ ```
65
+
66
+ - **cost**: tokens × the responses the source stayed in context, until a
67
+ compaction removed it. This is what you pay for over the session.
68
+ - **entered**: the tokens it brought in; **in**: the responses it stayed for.
69
+ - **chars/4** and **error**: what the usual estimate would say, beside it.
70
+
71
+ From Python:
72
+
73
+ ```python
74
+ from claude_context_cost import analyze, example_path
75
+
76
+ report = analyze(example_path()) # or the path of any .jsonl transcript
77
+ print(report.total) # 159160: input tokens summed over the 7 responses
78
+ read = report.sources[("tool", "Read")]
79
+ print(read.entered, read.responses, read.cost) # 3200 4 10400
80
+ print(read.estimate_div4) # 2000.0: characters / 4 for the same text
81
+ ```
82
+
83
+ `summarize(folder)` returns the same sum as a folder on the command line:
84
+ `.sources` maps `(kind, name, project)` to the totals, with `project` set only
85
+ for a project's own files.
86
+
87
+ ## Why
88
+
89
+ [claude-devtools](https://github.com/matt1398/claude-devtools) already shows
90
+ the context by source. Read in its code at commit `1486f20` (the latest on
91
+ 2026-10-10):
92
+
93
+ - Each source's tokens are characters / 4 (`src/main/utils/tokenizer.ts`);
94
+ only the turn total comes from the `usage`.
95
+ - Rules in `.claude/rules/` are listed as global sources without reading
96
+ their `paths:`, so a rule that never loaded still counts. Issue
97
+ [#224](https://github.com/matt1398/claude-devtools/issues/224), open since
98
+ 2026-07-17.
99
+ - `CLAUDE.md` and rules are read from disk today, not as they were during the
100
+ session.
101
+
102
+ This tool fixes the first two from the transcript alone.
103
+
104
+ ## What it counts
105
+
106
+ - **One response per `message.id`.** A response is written over several
107
+ lines with the same `usage`; it counts once.
108
+ - **Context = `input_tokens` + `cache_read_input_tokens` +
109
+ `cache_creation_input_tokens`.**
110
+ - **What entered** between two responses is the context growth minus the
111
+ previous response's output. When a single source entered, its tokens are
112
+ **measured**. When several did, the growth is **split** by their size.
113
+ - **At the start of the session and after each compaction** the context
114
+ jumps at once: each source is **estimated** with the bytes-per-token ratio
115
+ measured in the same transcript (median of its text-only steps; 2.25 when
116
+ it has fewer than 3), and the rest is **unattributed** (system prompt,
117
+ tool definitions). So every response adds up to its `usage`.
118
+ - **Rules with `paths:`** count only from the response where Claude Code
119
+ loaded them (`nested_memory`), never before.
120
+ - **Only what Claude Code rendered** into the context counts; bookkeeping
121
+ lines in the transcript do not.
122
+ - **A compaction** removes everything; messages it keeps count as "kept by
123
+ compaction", not as new responses.
124
+
125
+ ## How it is tested
126
+
127
+ - A synthetic transcript with known values (`tests/fixtures/build_example.py`)
128
+ where every source's tokens are worked out by hand: each one comes out
129
+ exact, every response adds up to its `usage`, a `paths:` rule that never
130
+ loaded counts 0, a response written over several lines counts once.
131
+ - `python tests/mutate.py` breaks each of those rules in a copy of the code
132
+ (17 mutants); the tests catch all 17.
133
+ - On the author's machine (2026-10-10): 75 session transcripts and 382
134
+ subagent transcripts, 91,347 responses, 0 where the sources differ from the
135
+ `usage`, 0 where the response count differs from an independent count, and
136
+ the sum over every project takes every transcript. The
137
+ ratio was measured in 50 of the 73 sessions with responses, median 2.22
138
+ bytes per token. Run it on yours with
139
+ `CLAUDE_CONTEXT_COST_CORPUS=1 python -m unittest tests.test_corpus -v`
140
+ (it prints only counts).
141
+
142
+ ## Limits
143
+
144
+ - **Split and estimated tokens are not measured.** The `how` column says
145
+ which is which. Many steps bring a tool result together with a small
146
+ reminder, so splitting is common.
147
+ - **Output includes thinking.** Thinking may leave the context later, so
148
+ the previous output can be overcounted; the remainder then shows as
149
+ negative unattributed tokens (191 of 91,347 responses).
150
+ - **A session's total does not include its subagents**; the sum of its
151
+ project's folder does.
152
+ - **English was measured in one session** (2026-10-10): 8 file reads, a
153
+ license, release notes, two READMEs, three Python modules and a git log;
154
+ median −40%, from −29% to −47%. The other numbers come from one person's
155
+ sessions, mostly in Portuguese.
156
+ - The transcript format is not documented and changes between versions.
157
+
158
+ ## Compatibility
159
+
160
+ Transcripts of Claude Code 2.1 (the synthetic fixture follows 2.1.293).
161
+ Python 3.11 to 3.14, tested on Linux and Windows.
162
+
163
+ Versions follow [Semantic Versioning](https://semver.org/): the names in
164
+ `claude_context_cost.__all__` and the keys of `--json` change incompatibly only
165
+ in a new major version. The transcript format is Claude Code's and changes
166
+ without notice; when it does, a fix keeps the API and ships as a minor or
167
+ patch version.
168
+
169
+ ## Contributing
170
+
171
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Never paste a real transcript in an
172
+ issue or pull request: it holds paths, file contents and sometimes secrets.
173
+
174
+ ## License
175
+
176
+ [MIT](LICENSE)
@@ -0,0 +1,15 @@
1
+ """How much each source costs in the context of a Claude Code session, counted from the usage."""
2
+ from pathlib import Path
3
+
4
+ from .attribute import Report, Source, analyze, attribute
5
+ from .summary import Summary, Total, summarize
6
+ from .transcript import read
7
+
8
+ __version__ = "1.3.1"
9
+ __all__ = ["Report", "Source", "analyze", "attribute", "example_path", "read", "Summary", "summarize",
10
+ "Total", "__version__"]
11
+
12
+
13
+ def example_path():
14
+ """A synthetic transcript shipped with the package, for trying the tool without a real one."""
15
+ return Path(__file__).resolve().parent / "example.jsonl"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,175 @@
1
+ """Gives every token of every response's input context to a source.
2
+
3
+ Between two responses of the same segment, the tokens that entered are known
4
+ exactly from the usage:
5
+
6
+ entered(n) = context(n) - context(n-1) - output(n-1)
7
+
8
+ One item in between: it gets all of them ("measured"). Several: they share
9
+ them in proportion to their bytes ("split"). At the first response of a
10
+ segment (session start or right after a compaction) the context also holds
11
+ the system prompt and the tools, which are not in the transcript: each item
12
+ gets its bytes divided by the transcript's own bytes-per-token ratio
13
+ ("estimated") and the remainder is unattributed.
14
+
15
+ So in every response the sources plus the unattributed add up to the usage,
16
+ and the cost of a source is its tokens summed over every response it stayed
17
+ in, until a compaction drops it.
18
+ """
19
+ import statistics
20
+ from dataclasses import dataclass, field
21
+
22
+ from .transcript import read
23
+
24
+ # Measured in October 2026 on 1,831 pairs from 12 projects (mostly Portuguese):
25
+ # 2.25 bytes per token. Used only when a transcript has too few pairs of its own.
26
+ DEFAULT_BYTES_PER_TOKEN = 2.25
27
+ MIN_PAIR_BYTES = 2000
28
+ MAX_PAIR_OUTPUT = 400 # the previous output includes thinking that may not come back
29
+ MIN_PAIRS = 3
30
+
31
+ OUTPUT = ("output", "responses")
32
+ FLOOR = ("unattributed", "system prompt, tools, overhead")
33
+
34
+
35
+ @dataclass
36
+ class Source:
37
+ kind: str
38
+ name: str
39
+ entered: int = 0 # tokens that entered the context, summed over every time it entered
40
+ bytes: int = 0
41
+ chars: int = 0
42
+ opaque: int = 0 # items whose size is not in the text (images, tool references)
43
+ cost: int = 0 # tokens x responses it stayed in
44
+ responses: int = 0
45
+ how: dict = field(default_factory=dict)
46
+
47
+ @property
48
+ def estimate_div4(self):
49
+ """What a characters / 4 estimate would say it entered, or None if it has no text."""
50
+ return self.chars / 4 if self.chars and not self.opaque else None
51
+
52
+
53
+ @dataclass
54
+ class Report:
55
+ path: str
56
+ responses: list
57
+ compactions: int
58
+ bad_lines: int
59
+ bytes_per_token: float
60
+ ratio_pairs: int
61
+ ratio_is_default: bool
62
+ sources: dict
63
+
64
+ @property
65
+ def total(self):
66
+ return sum(r.context for r in self.responses)
67
+
68
+
69
+ def allocate(total, weights):
70
+ """Integers proportional to the weights that add up to total exactly (largest remainder)."""
71
+ s = sum(weights)
72
+ if not weights or s <= 0:
73
+ return [0] * len(weights)
74
+ raw = [total * w / s for w in weights]
75
+ out = [int(x // 1) for x in raw]
76
+ order = sorted(range(len(raw)), key=lambda i: raw[i] - out[i], reverse=True)
77
+ for i in order[:total - sum(out)]:
78
+ out[i] += 1
79
+ return out
80
+
81
+
82
+ def calibrate(responses):
83
+ """Bytes per token from the steps where only text entered: the median, or the default."""
84
+ ratios = []
85
+ for prev, r in zip(responses, responses[1:]):
86
+ if r.segment_start or not r.entered or any(i.opaque for i in r.entered):
87
+ continue
88
+ delta = r.context - prev.context - prev.output
89
+ size = sum(i.bytes for i in r.entered)
90
+ if size >= MIN_PAIR_BYTES and prev.output < MAX_PAIR_OUTPUT and delta > 0:
91
+ ratios.append(size / delta)
92
+ if len(ratios) >= MIN_PAIRS:
93
+ return statistics.median(ratios), len(ratios), False
94
+ return DEFAULT_BYTES_PER_TOKEN, len(ratios), True
95
+
96
+
97
+ def _estimated(items, budget, ratio):
98
+ """Segment start: bytes / ratio for each item, never more than the budget; returns the rest."""
99
+ known = [i for i in items if not i.opaque]
100
+ est = round(sum(i.bytes for i in known) / ratio)
101
+ share = allocate(min(est, max(budget, 0)), [i.bytes for i in known])
102
+ for i, t in zip(known, share):
103
+ i.tokens, i.how = t, "estimated"
104
+ for i in items:
105
+ if i.opaque:
106
+ i.tokens, i.how = 0, "unknown"
107
+ return budget - sum(share)
108
+
109
+
110
+ def _split(items, delta, ratio):
111
+ """Between two responses: all of delta goes to the items; returns what is left unattributed."""
112
+ if delta <= 0:
113
+ for i in items:
114
+ i.tokens, i.how = 0, "shrank"
115
+ return delta
116
+ if len(items) == 1:
117
+ items[0].tokens, items[0].how = delta, "measured"
118
+ return 0
119
+ known = [i for i in items if not i.opaque]
120
+ opaque = [i for i in items if i.opaque]
121
+ if opaque:
122
+ text = min(round(sum(i.bytes for i in known) / ratio), delta)
123
+ shares = allocate(text, [i.bytes for i in known]) + allocate(delta - text, [1] * len(opaque))
124
+ ordered = known + opaque
125
+ else:
126
+ shares, ordered = allocate(delta, [i.bytes for i in known]), known
127
+ for i, t in zip(ordered, shares):
128
+ i.tokens, i.how = t, "split"
129
+ return delta - sum(shares)
130
+
131
+
132
+ def attribute(transcript):
133
+ ratio, pairs, default = calibrate(transcript.responses)
134
+ sources = {}
135
+
136
+ def source(key):
137
+ if key not in sources:
138
+ sources[key] = Source(*key)
139
+ return sources[key]
140
+
141
+ prev, ledger = None, {}
142
+ for r in transcript.responses:
143
+ if r.segment_start or prev is None:
144
+ ledger = {}
145
+ rest = _estimated(r.entered, r.context, ratio)
146
+ else:
147
+ ledger = dict(ledger)
148
+ ledger[OUTPUT] = ledger.get(OUTPUT, 0) + prev.output
149
+ source(OUTPUT).entered += prev.output
150
+ rest = _split(r.entered, r.context - prev.context - prev.output, ratio)
151
+ for i in r.entered:
152
+ ledger[i.key] = ledger.get(i.key, 0) + i.tokens
153
+ s = source(i.key)
154
+ s.entered += i.tokens
155
+ s.bytes += i.bytes
156
+ s.chars += i.chars
157
+ s.opaque += i.opaque
158
+ s.how[i.how] = s.how.get(i.how, 0) + 1
159
+ if rest:
160
+ ledger[FLOOR] = ledger.get(FLOOR, 0) + rest
161
+ source(FLOOR).entered += rest
162
+ ledger = {k: v for k, v in ledger.items() if v}
163
+ r.ledger = ledger
164
+ for k, v in ledger.items():
165
+ source(k).cost += v
166
+ source(k).responses += 1
167
+ prev = r
168
+ sources = {k: s for k, s in sources.items() if s.entered or s.cost}
169
+ return Report(transcript.path, transcript.responses, transcript.compactions, transcript.bad_lines,
170
+ ratio, pairs, default, sources)
171
+
172
+
173
+ def analyze(path):
174
+ """Read one transcript and attribute its context. See `Report`."""
175
+ return attribute(read(path))