confoundsearch 0.1.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.
Files changed (44) hide show
  1. confoundsearch/__init__.py +72 -0
  2. confoundsearch/__main__.py +5 -0
  3. confoundsearch/data/__init__.py +2 -0
  4. confoundsearch/data/corpus.py +36 -0
  5. confoundsearch/data/es.py +72 -0
  6. confoundsearch/data/sources.py +148 -0
  7. confoundsearch/dotenv.py +21 -0
  8. confoundsearch/errors.py +17 -0
  9. confoundsearch/http.py +26 -0
  10. confoundsearch/interface/__init__.py +1 -0
  11. confoundsearch/interface/cli.py +211 -0
  12. confoundsearch/interface/export.py +108 -0
  13. confoundsearch/interface/result.py +63 -0
  14. confoundsearch/interface/server.py +269 -0
  15. confoundsearch/interface/skill/SKILL.md +85 -0
  16. confoundsearch/interface/skill/__init__.py +0 -0
  17. confoundsearch/interface/ui/__init__.py +12 -0
  18. confoundsearch/interface/ui/app.html +128 -0
  19. confoundsearch/interface/ui/render.py +1234 -0
  20. confoundsearch/logic/__init__.py +2 -0
  21. confoundsearch/logic/actions/__init__.py +36 -0
  22. confoundsearch/logic/actions/add_confound.py +21 -0
  23. confoundsearch/logic/actions/add_edge.py +24 -0
  24. confoundsearch/logic/actions/attach_passage.py +48 -0
  25. confoundsearch/logic/actions/enumerate.py +38 -0
  26. confoundsearch/logic/actions/merge.py +24 -0
  27. confoundsearch/logic/actions/retrieve.py +32 -0
  28. confoundsearch/logic/actions/stop.py +17 -0
  29. confoundsearch/logic/audit.py +435 -0
  30. confoundsearch/logic/episode.py +113 -0
  31. confoundsearch/logic/graph.py +171 -0
  32. confoundsearch/logic/policy.py +111 -0
  33. confoundsearch/logic/reward.py +28 -0
  34. confoundsearch/logic/state.py +40 -0
  35. confoundsearch/logic/transition.py +55 -0
  36. confoundsearch/model/__init__.py +2 -0
  37. confoundsearch/model/llm.py +210 -0
  38. confoundsearch/py.typed +0 -0
  39. confoundsearch-0.1.0.dist-info/METADATA +98 -0
  40. confoundsearch-0.1.0.dist-info/RECORD +44 -0
  41. confoundsearch-0.1.0.dist-info/WHEEL +5 -0
  42. confoundsearch-0.1.0.dist-info/entry_points.txt +2 -0
  43. confoundsearch-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. confoundsearch-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,72 @@
1
+ """ConfoundSearch: an agent that builds a confound graph as a Markov Decision Process."""
2
+
3
+ from confoundsearch.data.corpus import Corpus, InMemoryCorpus
4
+ from confoundsearch.data.es import ElasticsearchCorpus
5
+ from confoundsearch.data.sources import OpenAlexCorpus, SemanticScholarCorpus
6
+ from confoundsearch.errors import CorpusError, InvalidAction, LLMError, RemoteError
7
+ from confoundsearch.interface.export import export_zip, save_export, to_share_data
8
+ from confoundsearch.logic.actions import (
9
+ Action,
10
+ AddConfound,
11
+ AddEdge,
12
+ AttachPassage,
13
+ Merge,
14
+ Retrieve,
15
+ Stop,
16
+ enumerate_actions,
17
+ )
18
+ from confoundsearch.logic.audit import AuditedClient, AuditLog, AuditTrail, verify
19
+ from confoundsearch.logic.episode import Step, Trajectory, run_episode
20
+ from confoundsearch.logic.graph import Graph, Passage, Vertex
21
+ from confoundsearch.logic.policy import EpsilonGreedy, LookaheadQ, Softmax
22
+ from confoundsearch.logic.reward import phi, reward
23
+ from confoundsearch.logic.state import State
24
+ from confoundsearch.logic.transition import Environment, Judge
25
+ from confoundsearch.model.llm import LLMJudge, LLMProposer, OpenAICompatibleClient, Proposer
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "Action",
31
+ "AddConfound",
32
+ "AddEdge",
33
+ "AttachPassage",
34
+ "AuditLog",
35
+ "AuditTrail",
36
+ "AuditedClient",
37
+ "Corpus",
38
+ "CorpusError",
39
+ "ElasticsearchCorpus",
40
+ "Environment",
41
+ "EpsilonGreedy",
42
+ "Graph",
43
+ "InMemoryCorpus",
44
+ "InvalidAction",
45
+ "Judge",
46
+ "LLMError",
47
+ "LLMJudge",
48
+ "LLMProposer",
49
+ "LookaheadQ",
50
+ "Merge",
51
+ "OpenAICompatibleClient",
52
+ "OpenAlexCorpus",
53
+ "Passage",
54
+ "Proposer",
55
+ "RemoteError",
56
+ "Retrieve",
57
+ "SemanticScholarCorpus",
58
+ "Softmax",
59
+ "State",
60
+ "Step",
61
+ "Stop",
62
+ "Trajectory",
63
+ "Vertex",
64
+ "enumerate_actions",
65
+ "export_zip",
66
+ "phi",
67
+ "reward",
68
+ "run_episode",
69
+ "save_export",
70
+ "to_share_data",
71
+ "verify",
72
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from confoundsearch.interface.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,2 @@
1
+ """The data layer: where papers come from (a file, Elasticsearch, Semantic Scholar, OpenAlex), all behind the
2
+ `Corpus` interface."""
@@ -0,0 +1,36 @@
1
+ """The corpus D, behind a one-method protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+ from confoundsearch.logic.graph import Passage, normalize
11
+
12
+
13
+ class Corpus(Protocol):
14
+ def search(self, query: str, k: int) -> list[Passage]:
15
+ """Return up to k passages relevant to the query."""
16
+ ...
17
+
18
+
19
+ class InMemoryCorpus:
20
+ """Ranks passages by how many query words they contain. Deterministic."""
21
+
22
+ def __init__(self, passages: Iterable[Passage]) -> None:
23
+ self.passages = tuple(passages)
24
+
25
+ @classmethod
26
+ def from_jsonl(cls, path: str | Path) -> InMemoryCorpus:
27
+ """One JSON object per line with `id` and `text`, and optionally `title` and `url`."""
28
+ rows = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
29
+ return cls(Passage(str(r["id"]), r["text"], r.get("title", ""), r.get("url", "")) for r in rows)
30
+
31
+ def search(self, query: str, k: int) -> list[Passage]:
32
+ words = set(normalize(query).split())
33
+ scored = [(len(words & set(normalize(p.text).split())), p) for p in self.passages]
34
+ hits = [(n, p) for n, p in scored if n > 0]
35
+ hits.sort(key=lambda hit: (-hit[0], hit[1].id))
36
+ return [p for _, p in hits[: max(k, 0)]]
@@ -0,0 +1,72 @@
1
+ """An Elasticsearch-backed corpus, over the plain REST API.
2
+
3
+ Works with a local node or Elastic Cloud. Documents need a text field (default
4
+ `abstract`) and may have `title`, `url` and `corpusid`, as in the Semantic
5
+ Scholar index the paper's experiments use.
6
+
7
+ ElasticsearchCorpus("https://my-deployment.es.us-east-1.aws.elastic.cloud", api_key="...")
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any
14
+
15
+ from confoundsearch.errors import CorpusError, RemoteError
16
+ from confoundsearch.http import Transport, urllib_transport
17
+ from confoundsearch.logic.graph import Passage
18
+
19
+
20
+ class ElasticsearchCorpus:
21
+ def __init__(
22
+ self,
23
+ url: str,
24
+ index: str = "papers",
25
+ api_key: str | None = None,
26
+ text_field: str = "abstract",
27
+ title_field: str = "title",
28
+ min_citations: int = 0,
29
+ timeout: float = 30.0,
30
+ transport: Transport = urllib_transport,
31
+ ) -> None:
32
+ self.url = url.rstrip("/")
33
+ self.index = index
34
+ self.api_key = api_key
35
+ self.text_field = text_field
36
+ self.title_field = title_field
37
+ self.min_citations = min_citations
38
+ self.timeout = timeout
39
+ self.transport = transport
40
+
41
+ def _query(self, query: str, k: int) -> dict[str, Any]:
42
+ match = {"multi_match": {"query": query, "fields": [f"{self.title_field}^2", self.text_field]}}
43
+ bool_query: dict[str, Any] = {"must": match, "filter": [{"exists": {"field": self.text_field}}]}
44
+ if self.min_citations:
45
+ bool_query["filter"].append({"range": {"citationcount": {"gte": self.min_citations}}})
46
+ return {"query": {"bool": bool_query}, "size": k}
47
+
48
+ def search(self, query: str, k: int) -> list[Passage]:
49
+ if k <= 0:
50
+ return []
51
+ headers = {"Content-Type": "application/json"}
52
+ if self.api_key:
53
+ headers["Authorization"] = f"ApiKey {self.api_key}"
54
+ body = json.dumps(self._query(query, k)).encode()
55
+ try:
56
+ raw = self.transport(f"{self.url}/{self.index}/_search", headers, body, self.timeout)
57
+ except RemoteError as err:
58
+ raise CorpusError(str(err)) from err
59
+ try:
60
+ hits = json.loads(raw)["hits"]["hits"]
61
+ return [self._passage(hit) for hit in hits]
62
+ except (ValueError, KeyError, TypeError) as err:
63
+ raise CorpusError(f"unexpected response: {raw[:200]!r}") from err
64
+
65
+ def _passage(self, hit: dict[str, Any]) -> Passage:
66
+ source = hit["_source"]
67
+ return Passage(
68
+ id=str(source.get("corpusid", hit["_id"])),
69
+ text=source[self.text_field],
70
+ title=source.get(self.title_field) or "",
71
+ url=source.get("url") or "",
72
+ )
@@ -0,0 +1,148 @@
1
+ """Paper sources that need no corpus of your own: Semantic Scholar's API and OpenAlex's.
2
+
3
+ Both implement the same one-method `Corpus` protocol as the file and Elasticsearch corpora, so the agent
4
+ does not know or care which it is searching. Only papers that have an abstract are returned, because an
5
+ abstract is the passage the agent reads and cites.
6
+
7
+ SemanticScholarCorpus(api_key="...") # a free key raises the rate limit; request one at S2_KEY_URL
8
+ OpenAlexCorpus(mailto="you@example.org") # an email (or an api_key) puts you in OpenAlex's polite pool
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import Mapping
15
+ from typing import Any
16
+ from urllib.parse import urlencode
17
+
18
+ from confoundsearch.errors import CorpusError, RemoteError
19
+ from confoundsearch.http import Transport, urllib_transport
20
+ from confoundsearch.logic.graph import Passage
21
+
22
+ MAX_PAGE = 100
23
+ S2_KEY_URL = "https://www.semanticscholar.org/product/api" # where to request a free Semantic Scholar API key
24
+
25
+
26
+ def _get(transport: Transport, url: str, headers: Mapping[str, str], timeout: float, hint: str) -> Any:
27
+ try:
28
+ raw = transport(url, headers, None, timeout)
29
+ except RemoteError as err:
30
+ extra = f" {hint}" if "HTTP 429" in str(err) else ""
31
+ raise CorpusError(f"{err}{extra}") from err
32
+ try:
33
+ return json.loads(raw)
34
+ except ValueError as err:
35
+ raise CorpusError(f"unexpected response: {raw[:200]!r}") from err
36
+
37
+
38
+ def _page_size(k: int) -> int:
39
+ """Ask for extra results, since some papers have no abstract and are dropped."""
40
+ return min(max(2 * k, k), MAX_PAGE)
41
+
42
+
43
+ class SemanticScholarCorpus:
44
+ def __init__(
45
+ self,
46
+ api_key: str | None = None,
47
+ base_url: str = "https://api.semanticscholar.org/graph/v1",
48
+ timeout: float = 30.0,
49
+ transport: Transport = urllib_transport,
50
+ ) -> None:
51
+ self.api_key = api_key
52
+ self.base_url = base_url.rstrip("/")
53
+ self.timeout = timeout
54
+ self.transport = transport
55
+
56
+ def search(self, query: str, k: int) -> list[Passage]:
57
+ if k <= 0 or not query.strip():
58
+ return []
59
+ params = urlencode(
60
+ {
61
+ "query": query,
62
+ "limit": _page_size(k),
63
+ "fields": "title,abstract,url,year,citationCount,corpusId",
64
+ }
65
+ )
66
+ headers = {"Accept": "application/json"}
67
+ if self.api_key:
68
+ headers["x-api-key"] = self.api_key
69
+ hint = (
70
+ f"Semantic Scholar is rate limiting you. Request a free API key at {S2_KEY_URL} "
71
+ "and set it as S2_KEY (or --s2-key); it raises the limit."
72
+ )
73
+ data = _get(self.transport, f"{self.base_url}/paper/search?{params}", headers, self.timeout, hint)
74
+ try:
75
+ papers = data.get("data") or [] # an empty result has no "data" at all
76
+ passages = [self._passage(p) for p in papers if p.get("abstract")]
77
+ except (AttributeError, KeyError, TypeError) as err:
78
+ raise CorpusError(f"unexpected response: {str(data)[:200]!r}") from err
79
+ return passages[:k]
80
+
81
+ @staticmethod
82
+ def _passage(paper: dict[str, Any]) -> Passage:
83
+ return Passage(
84
+ id=str(paper.get("corpusId") or paper["paperId"]),
85
+ text=paper["abstract"],
86
+ title=paper.get("title") or "",
87
+ url=paper.get("url") or "",
88
+ )
89
+
90
+
91
+ def abstract_from_inverted_index(inverted: Mapping[str, list[int]]) -> str:
92
+ """OpenAlex stores an abstract as {word: [positions]}. Put the words back in order."""
93
+ placed = sorted((position, word) for word, positions in inverted.items() for position in positions)
94
+ return " ".join(word for _, word in placed)
95
+
96
+
97
+ class OpenAlexCorpus:
98
+ def __init__(
99
+ self,
100
+ api_key: str | None = None,
101
+ mailto: str | None = None,
102
+ base_url: str = "https://api.openalex.org",
103
+ timeout: float = 30.0,
104
+ transport: Transport = urllib_transport,
105
+ ) -> None:
106
+ self.api_key = api_key
107
+ self.mailto = mailto
108
+ self.base_url = base_url.rstrip("/")
109
+ self.timeout = timeout
110
+ self.transport = transport
111
+
112
+ def search(self, query: str, k: int) -> list[Passage]:
113
+ if k <= 0 or not query.strip():
114
+ return []
115
+ params = {
116
+ "search": query,
117
+ "per_page": _page_size(k),
118
+ "select": "id,title,abstract_inverted_index,doi,publication_year",
119
+ }
120
+ if self.api_key:
121
+ params["api_key"] = self.api_key
122
+ if self.mailto:
123
+ params["mailto"] = self.mailto
124
+ hint = (
125
+ "OpenAlex is rate limiting you; add an email (OPENALEX_EMAIL) or an API key (OPENALEX_API_KEY)."
126
+ )
127
+ data = _get(
128
+ self.transport,
129
+ f"{self.base_url}/works?{urlencode(params)}",
130
+ {"Accept": "application/json"},
131
+ self.timeout,
132
+ hint,
133
+ )
134
+ try:
135
+ works = data["results"]
136
+ passages = [self._passage(w) for w in works if w.get("abstract_inverted_index")]
137
+ except (KeyError, TypeError, AttributeError) as err:
138
+ raise CorpusError(f"unexpected response: {str(data)[:200]!r}") from err
139
+ return passages[:k]
140
+
141
+ @staticmethod
142
+ def _passage(work: dict[str, Any]) -> Passage:
143
+ return Passage(
144
+ id=work["id"].rsplit("/", 1)[-1],
145
+ text=abstract_from_inverted_index(work["abstract_inverted_index"]),
146
+ title=work.get("title") or "",
147
+ url=work.get("doi") or work["id"],
148
+ )
@@ -0,0 +1,21 @@
1
+ """Read settings from a `.env` file, so API keys need not be typed into a terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def load_dotenv(path: Path) -> None:
10
+ """Put KEY=VALUE lines from `path` into the environment. Never overrides a variable already set."""
11
+ if not path.is_file():
12
+ return
13
+ for raw in path.read_text(encoding="utf-8").splitlines():
14
+ line = raw.strip().removeprefix("export ")
15
+ if not line or line.startswith("#") or "=" not in line:
16
+ continue
17
+ key, _, value = line.partition("=")
18
+ value = value.strip()
19
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
20
+ value = value[1:-1]
21
+ os.environ.setdefault(key.strip(), value)
@@ -0,0 +1,17 @@
1
+ """Exceptions shared across the package."""
2
+
3
+
4
+ class InvalidAction(ValueError):
5
+ """An edit or action violates a graph invariant or the MDP's rules."""
6
+
7
+
8
+ class RemoteError(RuntimeError):
9
+ """A remote HTTP service failed or returned something unusable."""
10
+
11
+
12
+ class LLMError(RemoteError):
13
+ """An OpenAI-compatible endpoint failed or returned something unusable."""
14
+
15
+
16
+ class CorpusError(RemoteError):
17
+ """The search backend failed or returned something unusable."""
confoundsearch/http.py ADDED
@@ -0,0 +1,26 @@
1
+ """One HTTP request (a POST with a body, or a GET without), using only the standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import urllib.error
6
+ import urllib.request
7
+ from collections.abc import Callable, Mapping
8
+
9
+ from confoundsearch.errors import RemoteError
10
+
11
+ Transport = Callable[[str, Mapping[str, str], bytes | None, float], bytes]
12
+
13
+
14
+ def urllib_transport(url: str, headers: Mapping[str, str], body: bytes | None, timeout: float) -> bytes:
15
+ request = urllib.request.Request(
16
+ url, data=body, headers=dict(headers), method="GET" if body is None else "POST"
17
+ )
18
+ try:
19
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
20
+ data: bytes = response.read()
21
+ return data
22
+ except urllib.error.HTTPError as err:
23
+ detail = err.read().decode("utf-8", "replace")
24
+ raise RemoteError(f"HTTP {err.code} from {url}: {detail}") from err
25
+ except urllib.error.URLError as err:
26
+ raise RemoteError(f"could not reach {url}: {err.reason}") from err
@@ -0,0 +1 @@
1
+ """The interface layer: the web page and its backend, the command line, the JSON result, the agent skill."""
@@ -0,0 +1,211 @@
1
+ """Command line: `confoundsearch serve` runs the interface and its backend,
2
+ `confoundsearch run` runs one search and writes the standalone page.
3
+
4
+ confoundsearch serve --model gpt-4.1 # the hosted index (ES_URL, ES_API_KEY)
5
+ confoundsearch serve --model gpt-4.1 --corpus openalex # or semanticscholar, or --corpus-file F
6
+ confoundsearch run --iv "IT investment" --dv "firm performance" --corpus-file passages.jsonl
7
+ confoundsearch audit confoundsearch.logic.audit.jsonl # check and read a run's audit trail
8
+ confoundsearch run --json ... # the result as JSON, for programs and agents
9
+ confoundsearch install-skill # add the Claude Code skill
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import random
18
+ import sys
19
+ from collections.abc import Sequence
20
+ from importlib import resources
21
+ from pathlib import Path
22
+
23
+ from confoundsearch import __version__
24
+ from confoundsearch.data.corpus import Corpus, InMemoryCorpus
25
+ from confoundsearch.data.es import ElasticsearchCorpus
26
+ from confoundsearch.data.sources import OpenAlexCorpus, SemanticScholarCorpus
27
+ from confoundsearch.dotenv import load_dotenv
28
+ from confoundsearch.interface.export import to_share_data
29
+ from confoundsearch.interface.result import run_result
30
+ from confoundsearch.interface.server import RunManager, make_server
31
+ from confoundsearch.interface.ui import standalone_html
32
+ from confoundsearch.logic.audit import AuditedClient, AuditLog, AuditTrail
33
+ from confoundsearch.logic.episode import ActionSource, run_episode
34
+ from confoundsearch.logic.policy import EpsilonGreedy, LookaheadQ
35
+ from confoundsearch.logic.transition import Environment
36
+ from confoundsearch.model.llm import OPENROUTER_URL, LLMJudge, LLMProposer, OpenAICompatibleClient, Proposer
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(prog="confoundsearch", description=__doc__.split("\n\n")[0])
41
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
42
+ commands = parser.add_subparsers(dest="command", required=True)
43
+
44
+ shared = argparse.ArgumentParser(add_help=False)
45
+ llm = shared.add_argument_group("language model (any OpenAI-compatible endpoint)")
46
+ llm.add_argument("--model", required=True)
47
+ llm.add_argument("--base-url", help="default: $OPENAI_BASE_URL or https://api.openai.com/v1")
48
+ llm.add_argument("--api-key", help="default: $OPENAI_API_KEY (with --openrouter: $OPENROUTER_API_KEY)")
49
+ llm.add_argument(
50
+ "--openrouter", action="store_true", help="use OpenRouter; --model is a slug like openai/gpt-4.1"
51
+ )
52
+ env = os.environ.get
53
+ papers = shared.add_argument_group("papers to search (default: the hosted Elasticsearch index)")
54
+ papers.add_argument(
55
+ "--corpus", choices=CORPORA, help="elasticsearch (default), semanticscholar, openalex, or file"
56
+ )
57
+ papers.add_argument("--corpus-file", type=Path, help="JSONL with id, text, and optionally title, url")
58
+ papers.add_argument("--es-url", default=env("ES_URL"), help="Elasticsearch endpoint (default: $ES_URL)")
59
+ papers.add_argument("--es-index", default="papers")
60
+ papers.add_argument("--es-api-key", default=env("ES_API_KEY"), help="default: $ES_API_KEY")
61
+ papers.add_argument(
62
+ "--s2-key", default=env("S2_KEY"), help="Semantic Scholar API key (optional; $S2_KEY)"
63
+ )
64
+ papers.add_argument(
65
+ "--openalex-key", default=env("OPENALEX_API_KEY"), help="OpenAlex API key ($OPENALEX_API_KEY)"
66
+ )
67
+ papers.add_argument(
68
+ "--openalex-email", default=env("OPENALEX_EMAIL"), help="email for OpenAlex ($OPENALEX_EMAIL)"
69
+ )
70
+
71
+ serve = commands.add_parser("serve", parents=[shared], help="serve the interface and its backend")
72
+ serve.add_argument("--host", default="127.0.0.1")
73
+ serve.add_argument("--port", type=int, default=7860)
74
+ serve.add_argument("--max-concurrent", type=int, default=2)
75
+ serve.add_argument("--audit-dir", type=Path, help="also write every run's audit trail to this folder")
76
+
77
+ run = commands.add_parser("run", parents=[shared], help="run one search and write the standalone page")
78
+ run.add_argument("--iv", required=True)
79
+ run.add_argument("--dv", required=True)
80
+ run.add_argument("--max-steps", type=int, default=40)
81
+ run.add_argument("--epsilon", type=float, default=0.1)
82
+ run.add_argument("--seed", type=int)
83
+ run.add_argument("--out", type=Path, default=Path("confoundsearch.html"))
84
+ run.add_argument("--audit", type=Path, help="where to write the audit trail (default: next to --out)")
85
+ run.add_argument(
86
+ "--json", action="store_true", help="print the result as JSON (and nothing else) to stdout"
87
+ )
88
+
89
+ audit = commands.add_parser("audit", help="check and read an audit trail written by a run")
90
+ audit.add_argument("file", type=Path, help="an audit trail (.jsonl)")
91
+ audit.add_argument(
92
+ "--json", action="store_true", help="print a machine-readable summary instead of the report"
93
+ )
94
+
95
+ skill = commands.add_parser("install-skill", help="install the Claude Code skill for ConfoundSearch")
96
+ skill.add_argument("--dir", type=Path, help="skills folder (default: ~/.claude/skills)")
97
+ skill.add_argument("--project", action="store_true", help="install into ./.claude/skills instead")
98
+ skill.add_argument("--force", action="store_true", help="overwrite a different SKILL.md")
99
+ skill.add_argument(
100
+ "--print", dest="show", action="store_true", help="print the skill instead of installing it"
101
+ )
102
+ return parser
103
+
104
+
105
+ CORPORA = ("elasticsearch", "semanticscholar", "openalex", "file")
106
+
107
+
108
+ def build_corpus(args: argparse.Namespace) -> Corpus:
109
+ """The papers to search. With nothing chosen, this is the hosted Elasticsearch index."""
110
+ kind = args.corpus or ("file" if args.corpus_file else "elasticsearch")
111
+ if args.corpus_file and kind != "file":
112
+ raise SystemExit("error: --corpus-file goes with --corpus file, not with another source")
113
+ if kind == "file":
114
+ if not args.corpus_file:
115
+ raise SystemExit("error: --corpus file needs --corpus-file PATH")
116
+ return InMemoryCorpus.from_jsonl(args.corpus_file)
117
+ if kind == "semanticscholar":
118
+ return SemanticScholarCorpus(args.s2_key)
119
+ if kind == "openalex":
120
+ return OpenAlexCorpus(args.openalex_key, args.openalex_email)
121
+ if not args.es_url:
122
+ raise SystemExit(
123
+ "error: the hosted Elasticsearch index needs ES_URL and ES_API_KEY (put them in a .env file), "
124
+ "or pick another source: --corpus semanticscholar, --corpus openalex, or --corpus-file PATH"
125
+ )
126
+ return ElasticsearchCorpus(args.es_url, args.es_index, args.es_api_key)
127
+
128
+
129
+ def build_client(args: argparse.Namespace) -> OpenAICompatibleClient:
130
+ """The AI model. With --openrouter it is OpenRouter, using your own OPENROUTER_API_KEY."""
131
+ if not args.openrouter:
132
+ return OpenAICompatibleClient(args.model, base_url=args.base_url, api_key=args.api_key)
133
+ key = args.api_key or os.environ.get("OPENROUTER_API_KEY")
134
+ if not key:
135
+ raise SystemExit("error: --openrouter needs OPENROUTER_API_KEY (put it in a .env file) or --api-key")
136
+ return OpenAICompatibleClient(args.model, base_url=args.base_url or OPENROUTER_URL, api_key=key)
137
+
138
+
139
+ def build_backend(args: argparse.Namespace) -> tuple[Environment, ActionSource]:
140
+ client = AuditedClient(build_client(args))
141
+ corpus = build_corpus(args)
142
+ return Environment(corpus, LLMJudge(client)), Proposer(LLMProposer(client))
143
+
144
+
145
+ def skill_text() -> str:
146
+ return resources.files("confoundsearch.interface.skill").joinpath("SKILL.md").read_text(encoding="utf-8")
147
+
148
+
149
+ def install_skill(args: argparse.Namespace) -> int:
150
+ """Copy the packaged SKILL.md to <skills folder>/confoundsearch/SKILL.md, where Claude Code finds it."""
151
+ text = skill_text()
152
+ if args.show:
153
+ print(text, end="")
154
+ return 0
155
+ default = Path.cwd() / ".claude" / "skills" if args.project else Path.home() / ".claude" / "skills"
156
+ target = (args.dir or default) / "confoundsearch" / "SKILL.md"
157
+ if target.exists():
158
+ if target.read_text(encoding="utf-8") == text:
159
+ print(f"already installed: {target}")
160
+ return 0
161
+ if not args.force:
162
+ print(f"error: {target} exists and differs; use --force to overwrite it", file=sys.stderr)
163
+ return 1
164
+ target.parent.mkdir(parents=True, exist_ok=True)
165
+ target.write_text(text, encoding="utf-8")
166
+ print(f"installed {target}")
167
+ return 0
168
+
169
+
170
+ def main(argv: Sequence[str] | None = None) -> int:
171
+ load_dotenv(Path.cwd() / ".env") # before the parser reads its defaults from the environment
172
+ args = build_parser().parse_args(argv)
173
+ if args.command == "install-skill":
174
+ return install_skill(args)
175
+ if args.command == "audit":
176
+ trail = AuditTrail.load(args.file)
177
+ if args.json:
178
+ print(json.dumps({"file": str(args.file), **trail.to_dict()}, indent=2))
179
+ else:
180
+ print(trail.to_markdown(), end="")
181
+ return 0 if trail.intact and trail.complete else 1
182
+ env, proposer = build_backend(args)
183
+ if args.command == "serve":
184
+ server = make_server(
185
+ RunManager(env, proposer, args.max_concurrent, audit_dir=args.audit_dir), args.host, args.port
186
+ )
187
+ print(f"ConfoundSearch at http://{args.host}:{server.server_port}", file=sys.stderr)
188
+ try:
189
+ server.serve_forever()
190
+ except KeyboardInterrupt:
191
+ pass
192
+ finally:
193
+ server.server_close()
194
+ return 0
195
+ policy = EpsilonGreedy(
196
+ LookaheadQ(env.preview), args.epsilon, random.Random(args.seed), explore_stop=False
197
+ )
198
+ audit_path = args.audit or args.out.with_name(f"{args.out.stem}.audit.jsonl")
199
+ with AuditLog(audit_path) as log:
200
+ trajectory = run_episode(env, policy, proposer, args.iv, args.dv, args.max_steps, audit=log)
201
+ args.out.write_text(standalone_html(to_share_data(trajectory.final)), encoding="utf-8")
202
+ if args.json:
203
+ print(json.dumps(run_result(trajectory, args.out, audit_path), indent=2))
204
+ return 0
205
+ graph = trajectory.final.graph
206
+ print(f"{len(trajectory.steps)} steps, {len(graph.connected())} connected, phi={trajectory.score:.3f}")
207
+ print(f"wrote {args.out}")
208
+ trail = trajectory.audit_trail()
209
+ chain = "intact" if trail.intact else "BROKEN"
210
+ print(f"audit trail {audit_path} ({len(trail.events)} events, hash chain {chain})")
211
+ return 0