citegraph 0.2.0__py3-none-any.whl

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,370 @@
1
+ """Does a documented tie between two authors show up in how the literature cites them?
2
+
3
+ A supervision record says that two people are connected socially. An author
4
+ co-citation network says which people the field reads together. This module
5
+ asks whether the first shows up in the second: for each tie, is the pair
6
+ co-cited more often than a *comparable* pair of strangers?
7
+
8
+ "Comparable" is the whole problem. Advisors and their students are prominent
9
+ people, and prominent people are co-cited with everyone, so a tie that beats an
10
+ average pair has proved nothing. The null here therefore holds prominence
11
+ fixed: each end of a tie is replaced by an author cited by a similar number of
12
+ bibliographies, and the tie is scored against the pairs that substitution
13
+ produces. What survives is co-citation the two ends' individual visibility does
14
+ not already explain.
15
+
16
+ Two further controls matter on a corpus whose bibliographies were written by
17
+ the people being studied:
18
+
19
+ - ``exclude_self_citations`` drops, for each pair, the bibliographies either
20
+ member wrote. What remains is co-citation by third parties — other people
21
+ putting the two names in one conversation. **The exclusion defines a universe
22
+ rather than filtering one side of a comparison**: the tie's remaining
23
+ bibliographies become the eligible set, and the null pairs are then counted,
24
+ and prominence-matched, inside that same set. Filtering only the tie would
25
+ compare authors who lost most of their chances against authors who lost none
26
+ — on the corpus this was written for, 485 of 564 co-cited authors wrote none
27
+ of the bibliographies at all, while one end of a tie wrote every bibliography
28
+ that cited them. That comparison is not conservative, it is wrong.
29
+ - Combined inference needs independent p-values. :func:`fisher_combined_p` is
30
+ retained as an assumption-bound diagnostic, but supervision ties that share
31
+ people are dependent, so it is not confirmatory evidence for overlapping ties.
32
+
33
+ Nothing here is Gemini- or provider-specific: it reads the finished tables.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import math
39
+ import random
40
+ from collections.abc import Iterable, Sequence
41
+ from dataclasses import dataclass
42
+
43
+ import pandas as pd
44
+
45
+ from citegraph.graph import CitationGraph, author_mentions
46
+
47
+ __all__ = [
48
+ "TieCocitation",
49
+ "SkippedTie",
50
+ "TieCocitationTest",
51
+ "tie_cocitation_test",
52
+ "fisher_combined_p",
53
+ ]
54
+
55
+ #: Columns of :meth:`TieCocitationTest.to_frame`, in order.
56
+ TIE_COLUMNS = [
57
+ "source",
58
+ "target",
59
+ "source_name",
60
+ "target_name",
61
+ "observed",
62
+ "null_median",
63
+ "null_mean",
64
+ "p_value",
65
+ "pool_size",
66
+ "n_eligible",
67
+ "n_draws",
68
+ ]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class TieCocitation:
73
+ """One tie scored against its degree-matched null.
74
+
75
+ ``observed`` is the number of eligible bibliographies citing both ends.
76
+ ``null_median`` / ``null_mean`` summarise the same count over pairs drawn
77
+ from the two ends' prominence strata, and ``p_value`` is the share of those
78
+ draws reaching ``observed`` — counting the observation itself, so it can
79
+ never be reported as zero. ``pool_size`` is the smaller of the two strata:
80
+ a tie scored against a handful of comparable authors carries a p-value with
81
+ very few distinct values, whatever the draw count says.
82
+
83
+ ``n_eligible`` is how many bibliographies the tie was scored over. It
84
+ equals the corpus total unless self-citations were excluded, and then it is
85
+ the number that remained — the honest denominator, and the thing to look at
86
+ before reading anything into a tie that came out at zero.
87
+ """
88
+
89
+ source: str
90
+ target: str
91
+ source_name: str
92
+ target_name: str
93
+ observed: int
94
+ null_median: float
95
+ null_mean: float
96
+ p_value: float
97
+ pool_size: int
98
+ n_eligible: int
99
+ n_draws: int
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class SkippedTie:
104
+ """A tie the data cannot carry, kept so a caller can report the shortfall."""
105
+
106
+ source: str
107
+ target: str
108
+ reason: str
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class TieCocitationTest:
113
+ """Every tie that could be scored, every tie that could not, and the totals."""
114
+
115
+ ties: tuple[TieCocitation, ...]
116
+ skipped: tuple[SkippedTie, ...]
117
+ n_citing_works: int
118
+ n_network_authors: int
119
+ n_draws: int
120
+ alpha: float
121
+ exclude_self_citations: bool
122
+
123
+ @property
124
+ def n_ties(self) -> int:
125
+ return len(self.ties)
126
+
127
+ @property
128
+ def n_significant(self) -> int:
129
+ """Ties whose own permutation p-value falls below ``alpha``."""
130
+ return sum(1 for tie in self.ties if tie.p_value < self.alpha)
131
+
132
+ @property
133
+ def expected_significant(self) -> float:
134
+ """How many of that count chance alone would be expected to produce."""
135
+ return self.alpha * self.n_ties
136
+
137
+ @property
138
+ def fisher_p_value(self) -> float | None:
139
+ """Fisher's combination of the per-tie p-values.
140
+
141
+ This is only interpretable when the supplied ties are independent.
142
+ Overlapping supervision ties violate that assumption, so callers must
143
+ treat this as a diagnostic rather than confirmatory evidence.
144
+ """
145
+ return fisher_combined_p([tie.p_value for tie in self.ties])
146
+
147
+ def to_frame(self) -> pd.DataFrame:
148
+ """One row per scored tie, in the order the ties were supplied."""
149
+ if not self.ties:
150
+ return pd.DataFrame(columns=TIE_COLUMNS)
151
+ return pd.DataFrame(
152
+ [{column: getattr(tie, column) for column in TIE_COLUMNS} for tie in self.ties]
153
+ )
154
+
155
+
156
+ def tie_cocitation_test(
157
+ graph: CitationGraph,
158
+ ties: Iterable[tuple[str, str]],
159
+ *,
160
+ min_papers: int = 3,
161
+ n_draws: int = 2000,
162
+ degree_tolerance: float = 0.2,
163
+ exclude_self_citations: bool = False,
164
+ alpha: float = 0.05,
165
+ seed: int = 0,
166
+ ) -> TieCocitationTest:
167
+ """Score each ``(source, target)`` author pair against a degree-matched null.
168
+
169
+ ``min_papers`` is the co-citation network's inclusion threshold and must
170
+ match the network a companion figure draws, or the test and the picture
171
+ describe different populations. ``degree_tolerance`` is the half-width of
172
+ the prominence stratum a substitute is drawn from, as a fraction of the
173
+ replaced author's citing-bibliography count, with a floor of one
174
+ bibliography — at a threshold of three, a 20% band would otherwise admit
175
+ only exact matches.
176
+
177
+ Each tie seeds its own generator from its own identity, so the numbers a
178
+ tie gets do not depend on where it sat in ``ties`` or on how many ties came
179
+ with it. A pair given twice, or given both ways round, is scored once.
180
+ """
181
+ graph._require_authors()
182
+ if n_draws < 1:
183
+ raise ValueError(f"n_draws must be at least 1, got {n_draws}")
184
+ if not 0 < alpha < 1:
185
+ raise ValueError(f"alpha must lie in (0, 1), got {alpha}")
186
+
187
+ mentions = author_mentions(graph.edges, graph.author_citations, min_papers=min_papers)
188
+ cited_by: dict[str, frozenset[str]] = {
189
+ author: frozenset(group)
190
+ for author, group in mentions.groupby("author_id")["citing_id"]
191
+ }
192
+ universe = sorted(cited_by)
193
+
194
+ citing_works = set(graph.edges["citing_id"].unique())
195
+ wrote = graph.author_citations[graph.author_citations["record_id"].isin(citing_works)]
196
+ authored_by: dict[str, frozenset[str]] = {
197
+ author: frozenset(group) for author, group in wrote.groupby("author_id")["record_id"]
198
+ }
199
+ names = graph.authors["display_name"] if "display_name" in graph.authors.columns else None
200
+
201
+ def display(author: str) -> str:
202
+ if names is None:
203
+ return author
204
+ return str(names.get(author, author))
205
+
206
+ def stratum(
207
+ author: str, exclude: Sequence[str], local_counts: dict[str, int]
208
+ ) -> list[str]:
209
+ width = max(1.0, degree_tolerance * local_counts[author])
210
+ blocked = set(exclude)
211
+ return [
212
+ other
213
+ for other in universe
214
+ if other not in blocked
215
+ and abs(local_counts[other] - local_counts[author]) <= width
216
+ ]
217
+
218
+ scored: list[TieCocitation] = []
219
+ skipped: list[SkippedTie] = []
220
+ seen: set[frozenset[str]] = set()
221
+
222
+ for source, target in ties:
223
+ if source == target:
224
+ skipped.append(SkippedTie(source, target, "both ends are the same author"))
225
+ continue
226
+ key = frozenset((source, target))
227
+ if key in seen:
228
+ continue
229
+ missing = [end for end in (source, target) if end not in cited_by]
230
+ if missing:
231
+ skipped.append(
232
+ SkippedTie(
233
+ source,
234
+ target,
235
+ f"not in the co-citation network at min_papers={min_papers}: "
236
+ + ", ".join(missing),
237
+ )
238
+ )
239
+ continue
240
+ seen.add(key)
241
+
242
+ # The eligible bibliographies are this tie's universe. Every count
243
+ # below — the tie's, the strata's, the null draws' — is taken inside
244
+ # it, so the tie and its comparison pairs had the same chances.
245
+ if exclude_self_citations:
246
+ eligible = (
247
+ citing_works
248
+ - authored_by.get(source, frozenset())
249
+ - authored_by.get(target, frozenset())
250
+ )
251
+ local_cited = {author: works & eligible for author, works in cited_by.items()}
252
+ else:
253
+ eligible = citing_works
254
+ local_cited = cited_by
255
+ local_counts = {author: len(works) for author, works in local_cited.items()}
256
+
257
+ def shared(first: str, second: str, _cited: dict[str, frozenset[str]] = local_cited) -> int:
258
+ return len(_cited[first] & _cited[second])
259
+
260
+ source_pool = stratum(source, (source, target), local_counts)
261
+ target_pool = stratum(target, (source, target), local_counts)
262
+ empty_ends = [
263
+ author
264
+ for author, pool in ((source, source_pool), (target, target_pool))
265
+ if not pool
266
+ ]
267
+ if empty_ends:
268
+ skipped.append(
269
+ SkippedTie(
270
+ source,
271
+ target,
272
+ "no degree-matched substitute for: " + ", ".join(empty_ends),
273
+ )
274
+ )
275
+ continue
276
+ if len(set(source_pool) | set(target_pool)) < 2:
277
+ skipped.append(
278
+ SkippedTie(source, target, "no comparable pair of authors to draw a null from")
279
+ )
280
+ continue
281
+
282
+ observed = shared(source, target)
283
+ low, high = sorted((source, target))
284
+ rng = random.Random(f"{seed}|{low}|{high}")
285
+ draws: list[int] = []
286
+ # Two draws can collide on one author; redraw rather than score a
287
+ # self-pair, and cap the attempts so a degenerate stratum cannot spin.
288
+ attempts = 0
289
+ while len(draws) < n_draws and attempts < n_draws * 20:
290
+ attempts += 1
291
+ first = rng.choice(source_pool)
292
+ second = rng.choice(target_pool)
293
+ if first == second:
294
+ continue
295
+ draws.append(shared(first, second))
296
+ if not draws: # pragma: no cover - guarded by the stratum size check
297
+ skipped.append(SkippedTie(source, target, "null sampling produced no valid pair"))
298
+ continue
299
+
300
+ at_least = sum(1 for value in draws if value >= observed)
301
+ scored.append(
302
+ TieCocitation(
303
+ source=source,
304
+ target=target,
305
+ source_name=display(source),
306
+ target_name=display(target),
307
+ observed=observed,
308
+ null_median=float(_median(draws)),
309
+ null_mean=float(sum(draws) / len(draws)),
310
+ p_value=(1 + at_least) / (1 + len(draws)),
311
+ pool_size=min(len(source_pool), len(target_pool)),
312
+ n_eligible=len(eligible),
313
+ n_draws=len(draws),
314
+ )
315
+ )
316
+
317
+ return TieCocitationTest(
318
+ ties=tuple(scored),
319
+ skipped=tuple(skipped),
320
+ n_citing_works=len(citing_works),
321
+ n_network_authors=len(universe),
322
+ n_draws=n_draws,
323
+ alpha=alpha,
324
+ exclude_self_citations=exclude_self_citations,
325
+ )
326
+
327
+
328
+ def _median(values: Sequence[float]) -> float:
329
+ ordered = sorted(values)
330
+ middle = len(ordered) // 2
331
+ if len(ordered) % 2:
332
+ return float(ordered[middle])
333
+ return (ordered[middle - 1] + ordered[middle]) / 2
334
+
335
+
336
+ # ----------------------------------------------------------------------
337
+ # Combination statistic
338
+ # ----------------------------------------------------------------------
339
+ def fisher_combined_p(pvalues: Iterable[float]) -> float | None:
340
+ """Combine independent p-values by Fisher's method.
341
+
342
+ ``-2 * sum(log p)`` is chi-squared on twice as many degrees of freedom.
343
+ The degrees of freedom are always even, so the survival function has the
344
+ closed form used here and no SciPy dependency is needed for it.
345
+
346
+ Returns ``None`` for an empty input. **The independence assumption is
347
+ real**: supervision ties that share an advisor are not independent, and
348
+ this will overstate the evidence for them.
349
+ """
350
+ values = [float(p) for p in pvalues]
351
+ if not values:
352
+ return None
353
+ for value in values:
354
+ if not 0.0 <= value <= 1.0:
355
+ raise ValueError(f"p-values must be between 0 and 1, got {value}")
356
+ if any(value == 0.0 for value in values):
357
+ return 0.0
358
+ statistic = -2.0 * sum(math.log(value) for value in values)
359
+ return _chi2_sf_even(statistic, 2 * len(values))
360
+
361
+
362
+ def _chi2_sf_even(statistic: float, df: int) -> float:
363
+ """``P(X > statistic)`` for chi-squared with an even number of df, exactly."""
364
+ half = statistic / 2.0
365
+ term = 1.0
366
+ total = 1.0
367
+ for i in range(1, df // 2):
368
+ term *= half / i
369
+ total += term
370
+ return math.exp(-half) * total
citegraph/config.py ADDED
@@ -0,0 +1,73 @@
1
+ """Runtime configuration for citegraph.
2
+
3
+ Reads from environment variables and an optional ``.env`` file via
4
+ ``pydantic-settings``. No file paths or secrets should be hardcoded
5
+ elsewhere in the package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from pydantic import Field
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+
16
+ class Settings(BaseSettings):
17
+ """Global, environment-driven settings."""
18
+
19
+ model_config = SettingsConfigDict(
20
+ env_file=".env",
21
+ env_file_encoding="utf-8",
22
+ env_prefix="",
23
+ extra="ignore",
24
+ )
25
+
26
+ google_api_key: str | None = Field(
27
+ default=None,
28
+ validation_alias="GOOGLE_API_KEY",
29
+ description="API key for the Google Gemini API.",
30
+ )
31
+ openalex_api_key: str | None = Field(
32
+ default=None,
33
+ validation_alias="OPENALEX_API_KEY",
34
+ description="API key for the OpenAlex API (raises the daily credit budget).",
35
+ )
36
+ citegraph_model: str = Field(
37
+ default="gemini-3.1-flash-lite",
38
+ validation_alias="CITEGRAPH_MODEL",
39
+ description="Default Gemini model identifier.",
40
+ )
41
+ citegraph_max_output_tokens: int = Field(
42
+ default=40_000,
43
+ validation_alias="CITEGRAPH_MAX_OUTPUT_TOKENS",
44
+ )
45
+ citegraph_request_timeout_s: float = Field(
46
+ default=120.0,
47
+ validation_alias="CITEGRAPH_REQUEST_TIMEOUT_S",
48
+ )
49
+ citegraph_llm_concurrency: int = Field(
50
+ default=4,
51
+ ge=1,
52
+ validation_alias="CITEGRAPH_LLM_CONCURRENCY",
53
+ description="Maximum concurrent LLM extraction calls.",
54
+ )
55
+
56
+ def require_api_key(self) -> str:
57
+ """Return the Gemini API key or raise a helpful error."""
58
+ if not self.google_api_key:
59
+ raise RuntimeError(
60
+ "GOOGLE_API_KEY is not set. Export it in your environment or "
61
+ "place it in a .env file in your working directory."
62
+ )
63
+ return self.google_api_key
64
+
65
+
66
+ def get_settings() -> Settings:
67
+ """Load settings (cheap; safe to call repeatedly)."""
68
+ return Settings()
69
+
70
+
71
+ def default_out_dir() -> Path:
72
+ """Default output directory used when the caller doesn't specify one."""
73
+ return Path.cwd() / "out"
@@ -0,0 +1,259 @@
1
+ """Pre-flight cost estimation for the LLM extraction stages.
2
+
3
+ Reads the on-disk markdown files (stage-1 output) and estimates Gemini token
4
+ usage for stages 2 (metadata) and 3 (references) before making any API calls.
5
+ Already-cached files are excluded from the estimate.
6
+
7
+ Library usage::
8
+
9
+ pipeline = Pipeline(pdf_dir=..., out_dir=...)
10
+ estimate = pipeline.estimate_extraction_cost()
11
+ print(estimate.format_summary())
12
+
13
+ CLI usage::
14
+
15
+ citegraph estimate --out ./out
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from dataclasses import dataclass, field
22
+
23
+ from citegraph.extract_metadata import DEFAULT_METADATA_INPUT_CHARS
24
+ from citegraph.extract_references import slice_to_references_section
25
+ from citegraph.io import OutLayout, read_pydantic_cache
26
+ from citegraph.schemas import PaperMetadata, Reference
27
+
28
+ # Rough heuristic: English/academic prose averages ~4 characters per token.
29
+ _CHARS_PER_TOKEN: int = 4
30
+
31
+ # Static prompt overhead per call (template chars + system instruction chars).
32
+ # Measured from the actual prompt strings in extract_metadata.py / extract_references.py.
33
+ _METADATA_OVERHEAD_CHARS: int = 560
34
+ _REFERENCES_OVERHEAD_CHARS: int = 1_060
35
+
36
+ # Output-token constants for cost projection.
37
+ _METADATA_OUTPUT_TOKENS: int = 300 # PaperMetadata JSON is small and bounded
38
+ _TOKENS_PER_REFERENCE: int = 75 # one Reference JSON object
39
+ _CHARS_PER_REFERENCE_APPROX: int = 175 # typical reference length in source text
40
+
41
+ # Pricing table: (input USD / 1M tokens, output USD / 1M tokens).
42
+ # Approximate standard paid-tier rates as of 2026-09-04 — check
43
+ # https://ai.google.dev/gemini-api/docs/pricing for
44
+ # current numbers before trusting these for budget planning.
45
+ _PRICING: dict[str, tuple[float, float]] = {
46
+ "gemini-2.5-pro-preview": (1.25, 10.00),
47
+ "gemini-2.5-flash-preview": (0.15, 0.60),
48
+ "gemini-2.5-flash": (0.15, 0.60),
49
+ "gemini-2.0-flash": (0.10, 0.40),
50
+ "gemini-2.0-flash-lite": (0.075, 0.30),
51
+ "gemini-1.5-pro": (1.25, 5.00),
52
+ "gemini-1.5-flash": (0.075, 0.30),
53
+ "gemini-1.5-flash-lite": (0.075, 0.30),
54
+ "gemini-3.1-flash-lite": (0.25, 1.50),
55
+ }
56
+
57
+
58
+ def _chars_to_tokens(n: int) -> int:
59
+ return math.ceil(n / _CHARS_PER_TOKEN)
60
+
61
+
62
+ @dataclass
63
+ class FileEstimate:
64
+ """Per-file breakdown of estimated token usage."""
65
+
66
+ name: str
67
+ metadata_cached: bool
68
+ references_cached: bool
69
+ metadata_input_tokens: int # 0 when cached
70
+ references_input_tokens: int # 0 when cached
71
+ estimated_n_references: int # 0 when cached
72
+
73
+
74
+ @dataclass
75
+ class ExtractionEstimate:
76
+ """Aggregate token and cost estimate for LLM extraction stages 2 and 3."""
77
+
78
+ model: str
79
+ files: list[FileEstimate] = field(default_factory=list)
80
+
81
+ @property
82
+ def n_files(self) -> int:
83
+ return len(self.files)
84
+
85
+ @property
86
+ def n_metadata_to_process(self) -> int:
87
+ return sum(1 for f in self.files if not f.metadata_cached)
88
+
89
+ @property
90
+ def n_references_to_process(self) -> int:
91
+ return sum(1 for f in self.files if not f.references_cached)
92
+
93
+ @property
94
+ def total_input_tokens(self) -> int:
95
+ return sum(f.metadata_input_tokens + f.references_input_tokens for f in self.files)
96
+
97
+ @property
98
+ def estimated_output_tokens(self) -> int:
99
+ meta_out = self.n_metadata_to_process * _METADATA_OUTPUT_TOKENS
100
+ refs_out = sum(
101
+ f.estimated_n_references * _TOKENS_PER_REFERENCE
102
+ for f in self.files
103
+ if not f.references_cached
104
+ )
105
+ return meta_out + refs_out
106
+
107
+ def cost_usd(self) -> tuple[float, float] | None:
108
+ """Return ``(input_cost_usd, output_cost_usd)``, or ``None`` for unknown models."""
109
+ pricing = _PRICING.get(self.model)
110
+ if pricing is None:
111
+ return None
112
+ in_price, out_price = pricing
113
+ return (
114
+ self.total_input_tokens * in_price / 1_000_000,
115
+ self.estimated_output_tokens * out_price / 1_000_000,
116
+ )
117
+
118
+ def format_references_summary(self) -> str:
119
+ """Return a concise cost summary covering only stage 3 (references extraction)."""
120
+ n_cached = self.n_files - self.n_references_to_process
121
+ cached_str = f" ({n_cached} cached)" if n_cached else ""
122
+
123
+ refs_input = sum(f.references_input_tokens for f in self.files)
124
+ refs_output = sum(
125
+ f.estimated_n_references * _TOKENS_PER_REFERENCE
126
+ for f in self.files
127
+ if not f.references_cached
128
+ )
129
+
130
+ lines = [
131
+ f"Model: {self.model}",
132
+ f"Files to process: {self.n_references_to_process}{cached_str}",
133
+ f"Est. input tokens: ~{refs_input:,}",
134
+ f"Est. output tokens: ~{refs_output:,}",
135
+ ]
136
+
137
+ pricing = _PRICING.get(self.model)
138
+ if pricing is not None:
139
+ in_price, out_price = pricing
140
+ in_cost = refs_input * in_price / 1_000_000
141
+ out_cost = refs_output * out_price / 1_000_000
142
+ lines.append(
143
+ f"Est. cost: ~${in_cost:.4f} input"
144
+ f" + ~${out_cost:.4f} output"
145
+ f" = ~${in_cost + out_cost:.4f} total"
146
+ )
147
+ else:
148
+ lines.append(
149
+ f"Est. cost: unknown (no pricing data for '{self.model}')"
150
+ )
151
+ lines.append("(Estimates are approximate; actual cost depends on response length.)")
152
+ return "\n".join(lines)
153
+
154
+ def format_summary(self) -> str:
155
+ """Return a multi-line human-readable cost summary."""
156
+ n_meta_cached = self.n_files - self.n_metadata_to_process
157
+ n_refs_cached = self.n_files - self.n_references_to_process
158
+
159
+ def _cached(n: int) -> str:
160
+ return f" ({n} cached)" if n else ""
161
+
162
+ lines = [
163
+ f"Model: {self.model}",
164
+ f"Files found: {self.n_files}",
165
+ f"Metadata to process: {self.n_metadata_to_process}{_cached(n_meta_cached)}",
166
+ f"References to process: {self.n_references_to_process}{_cached(n_refs_cached)}",
167
+ f"Est. input tokens: ~{self.total_input_tokens:,}",
168
+ f"Est. output tokens: ~{self.estimated_output_tokens:,}",
169
+ ]
170
+
171
+ cost = self.cost_usd()
172
+ if cost is not None:
173
+ in_c, out_c = cost
174
+ lines.append(
175
+ f"Est. cost: ~${in_c:.4f} input"
176
+ f" + ~${out_c:.4f} output"
177
+ f" = ~${in_c + out_c:.4f} total"
178
+ )
179
+ else:
180
+ lines.append(
181
+ f"Est. cost: unknown (no pricing data for '{self.model}')"
182
+ )
183
+ lines.append("(Estimates are approximate; actual cost depends on response length.)")
184
+ return "\n".join(lines)
185
+
186
+
187
+ def estimate_extraction_cost(
188
+ layout: OutLayout,
189
+ model: str,
190
+ max_input_chars_metadata: int = DEFAULT_METADATA_INPUT_CHARS,
191
+ ) -> ExtractionEstimate:
192
+ """Estimate LLM token usage for stages 2 and 3 without making any API calls.
193
+
194
+ Reads markdown files from ``layout.markdown_dir`` and skips files whose
195
+ metadata / references caches already exist.
196
+
197
+ Raises :class:`~citegraph.pipeline.StageNotReadyError` when no markdown
198
+ files are found (stage 1 must run first).
199
+ """
200
+ from citegraph.pipeline import StageNotReadyError, extraction_recipe
201
+
202
+ md_paths = layout.markdown_inputs()
203
+ if not md_paths:
204
+ raise StageNotReadyError(
205
+ f"No markdown files found in {layout.markdown_dir}. "
206
+ "Run `citegraph convert <pdf_dir>` first."
207
+ )
208
+
209
+ file_estimates: list[FileEstimate] = []
210
+ metadata_recipe = extraction_recipe(model)
211
+ metadata_recipe['max_input_chars'] = max_input_chars_metadata
212
+ references_recipe = extraction_recipe(model, references=True)
213
+ for md in md_paths:
214
+ metadata_cache = layout.metadata_dir / f"{md.stem}.json"
215
+ references_cache = layout.references_dir / f"{md.stem}.json"
216
+ meta_cached = read_pydantic_cache(metadata_cache, md, PaperMetadata, recipe=metadata_recipe) is not None
217
+ refs_cached = read_pydantic_cache(references_cache, md, Reference, many=True, recipe=references_recipe) is not None
218
+
219
+ if meta_cached and refs_cached:
220
+ file_estimates.append(
221
+ FileEstimate(
222
+ name=md.name,
223
+ metadata_cached=True,
224
+ references_cached=True,
225
+ metadata_input_tokens=0,
226
+ references_input_tokens=0,
227
+ estimated_n_references=0,
228
+ )
229
+ )
230
+ continue
231
+
232
+ content = md.read_text(encoding="utf-8")
233
+
234
+ if meta_cached:
235
+ meta_tokens = 0
236
+ else:
237
+ capped = min(len(content), max_input_chars_metadata) if max_input_chars_metadata else len(content)
238
+ meta_tokens = _chars_to_tokens(capped + _METADATA_OVERHEAD_CHARS)
239
+
240
+ if refs_cached:
241
+ refs_tokens = 0
242
+ est_n_refs = 0
243
+ else:
244
+ sliced, _ = slice_to_references_section(content)
245
+ refs_tokens = _chars_to_tokens(len(sliced) + _REFERENCES_OVERHEAD_CHARS)
246
+ est_n_refs = max(1, len(sliced) // _CHARS_PER_REFERENCE_APPROX)
247
+
248
+ file_estimates.append(
249
+ FileEstimate(
250
+ name=md.name,
251
+ metadata_cached=meta_cached,
252
+ references_cached=refs_cached,
253
+ metadata_input_tokens=meta_tokens,
254
+ references_input_tokens=refs_tokens,
255
+ estimated_n_references=est_n_refs,
256
+ )
257
+ )
258
+
259
+ return ExtractionEstimate(model=model, files=file_estimates)