dayan 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.
dayan/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """DaYan (大衍): thought-graph modeling, evaluation, and enhancement.
2
+
3
+ Named after the I Ching's 大衍之数 — the formal generative procedure that
4
+ produces structured hexagram states — DaYan models an LLM's reasoning
5
+ process as data: instrument a reasoning program, get its Thought Graph,
6
+ evaluate the *process* (five metrics), mine thinking patterns, and feed them
7
+ back as prompt rules or verifiable RL rewards. An auxiliary subpackage
8
+ (``dayan.augment``) provides symbolically verified data augmentation.
9
+
10
+ from dayan import analysis, AnalysisOutput, query, set_backend
11
+
12
+ set_backend(my_llm_fn) # inject any LLM
13
+ result, thought_graph = solve() # @analysis(output_type=THOUGHT)
14
+ """
15
+
16
+ from dayan.graph import (
17
+ AnalysisOutput,
18
+ DataFlowGraph,
19
+ ProgramTrace,
20
+ ThoughtGraph,
21
+ VariableNode,
22
+ analysis,
23
+ query,
24
+ query_iter,
25
+ set_backend,
26
+ )
27
+ from dayan.llm import LLM, ChatClient, FakeLLM
28
+ from dayan.metrics import (
29
+ ThoughtSample,
30
+ crucial_node_judgment,
31
+ enumerate_paths,
32
+ multipath_reliability,
33
+ node_influence,
34
+ path_distribution,
35
+ reasoning_commonality,
36
+ reasoning_efficiency,
37
+ stepwise_consistency,
38
+ )
39
+ from dayan.mining import Pattern, contrastive_patterns, mine_frequent_patterns
40
+ from dayan.optimize import (
41
+ PatternProcessReward,
42
+ inject_rules_into_prompt,
43
+ patterns_to_prompt_rules,
44
+ )
45
+
46
+ __version__ = "0.2.0"
47
+
48
+ __all__ = [
49
+ "AnalysisOutput",
50
+ "ChatClient",
51
+ "DataFlowGraph",
52
+ "FakeLLM",
53
+ "LLM",
54
+ "Pattern",
55
+ "PatternProcessReward",
56
+ "ProgramTrace",
57
+ "ThoughtGraph",
58
+ "ThoughtSample",
59
+ "VariableNode",
60
+ "__version__",
61
+ "analysis",
62
+ "contrastive_patterns",
63
+ "crucial_node_judgment",
64
+ "enumerate_paths",
65
+ "inject_rules_into_prompt",
66
+ "mine_frequent_patterns",
67
+ "multipath_reliability",
68
+ "node_influence",
69
+ "path_distribution",
70
+ "patterns_to_prompt_rules",
71
+ "query",
72
+ "query_iter",
73
+ "reasoning_commonality",
74
+ "reasoning_efficiency",
75
+ "set_backend",
76
+ "stepwise_consistency",
77
+ ]
@@ -0,0 +1,20 @@
1
+ """Trusted data augmentation subpackage (Logical DA).
2
+
3
+ Attribute extraction → diversity filtering → label-flipped generation →
4
+ symbolic label verification (Z3 / Prover9 / rule / CSP backends) → feedback
5
+ reflection. Orchestration goes through any injected LLM workflow; the
6
+ default is ``SimpleWorkflow`` (no CrewAI dependency).
7
+ """
8
+
9
+ from dayan.augment.pipeline import PipelineConfig, RunResult, run_dataset
10
+ from dayan.augment.symbolic_backends import SOLVERS, select_solver
11
+ from dayan.augment.workflow import SimpleWorkflow
12
+
13
+ __all__ = [
14
+ "PipelineConfig",
15
+ "RunResult",
16
+ "SOLVERS",
17
+ "SimpleWorkflow",
18
+ "run_dataset",
19
+ "select_solver",
20
+ ]
dayan/augment/data.py ADDED
@@ -0,0 +1,423 @@
1
+ """Dataset loading, schema inference, and format-preserving sample validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ _OPTION_RE = re.compile(
12
+ r"^\s*(?:\(([A-Za-z0-9]{1,3})\)|([A-Za-z0-9]{1,3})[).:])\s*(.*?)\s*$"
13
+ )
14
+ _SPACE_RE = re.compile(r"\s+")
15
+ _ALLOWED_SUFFIXES = {".json", ".jsonl"}
16
+
17
+
18
+ class DatasetError(ValueError):
19
+ """The input cannot be used as a labeled reasoning dataset."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Dataset:
24
+ path: Path
25
+ rows: list[dict[str, Any]]
26
+ source_format: str # "json" or "jsonl"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Schema:
31
+ kind: str # "nli", "qa", or "text"
32
+ text_fields: tuple[str, ...]
33
+ label_field: str
34
+ observed_labels: tuple[str, ...]
35
+ dataset_hint: str
36
+
37
+ def label_space(self, row: dict[str, Any]) -> tuple[str, ...]:
38
+ if self.kind == "qa" and isinstance(row.get("options"), list):
39
+ options = option_map(row["options"])
40
+ if options:
41
+ return tuple(options)
42
+
43
+ if self.kind == "nli":
44
+ hint = self.dataset_hint.upper()
45
+ observed = {canonical_relation(value) for value in self.observed_labels}
46
+ if hint == "CB" or {"contradiction", "neutral"} & observed:
47
+ return ("entailment", "contradiction", "neutral")
48
+ if hint == "RTE" or "not_entailment" in observed:
49
+ return ("entailment", "not_entailment")
50
+
51
+ if self.kind == "qa":
52
+ labels = tuple(str(value) for value in self.observed_labels)
53
+ if len(labels) > 8 or any(len(label) > 40 for label in labels):
54
+ return (str(row[self.label_field]),)
55
+ return labels
56
+
57
+ return self.observed_labels
58
+
59
+ def resolve_label(self, prediction: Any, row: dict[str, Any]) -> str | None:
60
+ if prediction is None:
61
+ return None
62
+ value = str(prediction).strip()
63
+ if not value:
64
+ return None
65
+ labels = self.label_space(row)
66
+ if self.kind == "qa" and isinstance(row.get("options"), list):
67
+ options = option_map(row["options"])
68
+ upper = value.upper()
69
+ if upper in options:
70
+ return upper
71
+ match = _OPTION_RE.match(value)
72
+ if match:
73
+ key = (match.group(1) or match.group(2)).upper()
74
+ if key in options:
75
+ return key
76
+ for key, text in options.items():
77
+ if value.casefold() == text.casefold():
78
+ return key
79
+ return None
80
+
81
+ if self.kind == "nli":
82
+ relation = canonical_relation(value)
83
+ allowed = {canonical_relation(label): label for label in labels}
84
+ # Index the entailment-form of every label too: datasets labeled
85
+ # plain "True"/"False" must resolve solver relations
86
+ # "entailment"/"contradiction" back to them (Logical DA z3 fix).
87
+ for label in labels:
88
+ canon = canonical_relation(label)
89
+ mapped = {
90
+ "true": "entailment",
91
+ "entailed": "entailment",
92
+ "false": "contradiction",
93
+ "contradicted": "contradiction",
94
+ }.get(canon)
95
+ if mapped:
96
+ allowed.setdefault(mapped, label)
97
+ if relation in allowed:
98
+ return allowed[relation]
99
+ relation = {
100
+ "true": "entailment",
101
+ "entailed": "entailment",
102
+ "false": "contradiction",
103
+ "contradicted": "contradiction",
104
+ }.get(relation, relation)
105
+ if relation in allowed:
106
+ return allowed[relation]
107
+ if relation in {"contradiction", "neutral"} and "not_entailment" in allowed:
108
+ return allowed["not_entailment"]
109
+ return None
110
+
111
+ for label in labels:
112
+ if value.casefold() == str(label).casefold():
113
+ return label
114
+ return None
115
+
116
+ def truth_label(self, truth: str, row: dict[str, Any]) -> str | None:
117
+ if truth not in {"true", "false", "unknown"}:
118
+ return None
119
+ if self.kind == "nli":
120
+ desired = {
121
+ "true": "entailment",
122
+ "false": "contradiction",
123
+ "unknown": "neutral",
124
+ }[truth]
125
+ return self.resolve_label(desired, row)
126
+ if self.kind == "qa" and isinstance(row.get("options"), list):
127
+ aliases = {
128
+ "true": {"true", "yes", "entailed"},
129
+ "false": {"false", "no", "contradicted"},
130
+ "unknown": {"unknown", "uncertain", "undetermined", "not enough information", "cannot be determined"},
131
+ }[truth]
132
+ for key, text in option_map(row["options"]).items():
133
+ normalized = _SPACE_RE.sub(" ", text.strip().lower())
134
+ if normalized in aliases:
135
+ return key
136
+ return None
137
+
138
+
139
+ def canonical_relation(value: Any) -> str:
140
+ key = re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
141
+ return {
142
+ "no_entailment": "not_entailment",
143
+ "non_entailment": "not_entailment",
144
+ "not_entailed": "not_entailment",
145
+ "contradictory": "contradiction",
146
+ "unknown": "neutral",
147
+ "uncertain": "neutral",
148
+ }.get(key, key)
149
+
150
+
151
+ def option_map(options: list[Any]) -> dict[str, str]:
152
+ result: dict[str, str] = {}
153
+ for option in options:
154
+ if not isinstance(option, str):
155
+ return {}
156
+ match = _OPTION_RE.match(option)
157
+ if not match:
158
+ return {}
159
+ key = (match.group(1) or match.group(2)).upper()
160
+ if key in result or not match.group(3).strip():
161
+ return {}
162
+ result[key] = match.group(3).strip()
163
+ return result
164
+
165
+
166
+ def discover_inputs(input_path: Path) -> list[Path]:
167
+ path = input_path.expanduser().resolve()
168
+ if path.is_file():
169
+ if path.suffix.lower() not in _ALLOWED_SUFFIXES:
170
+ raise DatasetError(f"Unsupported input extension: {path}")
171
+ return [path]
172
+ if not path.is_dir():
173
+ raise DatasetError(f"Input does not exist: {path}")
174
+
175
+ candidates = sorted(
176
+ candidate for candidate in path.rglob("*")
177
+ if candidate.is_file()
178
+ and candidate.suffix.lower() in _ALLOWED_SUFFIXES
179
+ and not any(part.startswith(".") for part in candidate.relative_to(path).parts)
180
+ )
181
+ training = [
182
+ candidate for candidate in candidates
183
+ if candidate.stem.lower() == "train"
184
+ or candidate.stem.lower().startswith("train_")
185
+ ]
186
+ if training:
187
+ # Some legacy folders contain the same JSON array twice, as train.json
188
+ # and train.jsonl. Prefer the clearly named JSON file in directory mode.
189
+ chosen: dict[tuple[Path, str], Path] = {}
190
+ for candidate in training:
191
+ key = (candidate.parent, candidate.stem.lower())
192
+ if key not in chosen or candidate.suffix.lower() == ".json":
193
+ chosen[key] = candidate
194
+ return sorted(chosen.values())
195
+ if len(candidates) == 1:
196
+ return candidates
197
+ if not candidates:
198
+ raise DatasetError(f"No JSON or JSONL data files found under {path}")
199
+ raise DatasetError(
200
+ f"{path} contains multiple data files but no train.json/train.jsonl. "
201
+ "Pass one file explicitly to avoid augmenting evaluation data."
202
+ )
203
+
204
+
205
+ def load_dataset(path: Path) -> Dataset:
206
+ raw = path.read_text(encoding="utf-8-sig")
207
+ if not raw.strip():
208
+ raise DatasetError(f"Input is empty: {path}")
209
+
210
+ try:
211
+ parsed = json.loads(raw)
212
+ except json.JSONDecodeError:
213
+ rows: list[Any] = []
214
+ for line_number, line in enumerate(raw.splitlines(), start=1):
215
+ if not line.strip():
216
+ continue
217
+ try:
218
+ rows.append(json.loads(line))
219
+ except json.JSONDecodeError as exc:
220
+ raise DatasetError(
221
+ f"{path}:{line_number}: invalid JSONL: {exc.msg}"
222
+ ) from exc
223
+ source_format = "jsonl"
224
+ else:
225
+ if isinstance(parsed, list):
226
+ rows = parsed
227
+ elif isinstance(parsed, dict) and isinstance(parsed.get("data"), list):
228
+ rows = parsed["data"]
229
+ elif isinstance(parsed, dict):
230
+ rows = [parsed]
231
+ else:
232
+ raise DatasetError(f"{path}: top-level JSON must be an object or array")
233
+ source_format = (
234
+ "jsonl"
235
+ if isinstance(parsed, dict)
236
+ and not isinstance(parsed.get("data"), list)
237
+ and path.suffix.lower() == ".jsonl"
238
+ else "json"
239
+ )
240
+
241
+ if not rows:
242
+ raise DatasetError(f"Input contains no records: {path}")
243
+ for index, row in enumerate(rows):
244
+ if not isinstance(row, dict):
245
+ raise DatasetError(f"{path}: record {index} is not a JSON object")
246
+ return Dataset(path=path, rows=rows, source_format=source_format)
247
+
248
+
249
+ def _dataset_hint(dataset: Dataset) -> str:
250
+ """Keep automatic solver routing when an original dataset file is moved."""
251
+ parent = dataset.path.parent.name
252
+ known = {
253
+ "folio": "FOLIO",
254
+ "proofwriter": "ProofWriter",
255
+ "prontoqa": "ProntoQA",
256
+ "logicaldeduction": "LogicalDeduction",
257
+ "arlsat": "AR-LSAT",
258
+ "cb": "CB",
259
+ "rte": "RTE",
260
+ }
261
+ normalized = re.sub(r"[^a-z0-9]", "", parent.lower())
262
+ if normalized in known:
263
+ return known[normalized]
264
+ identifier = str(dataset.rows[0].get("id", ""))
265
+ for prefix, hint in (
266
+ (r"^folio[_-]", "FOLIO"),
267
+ (r"^proofwriter[_-]", "ProofWriter"),
268
+ (r"^prontoqa[_-]", "ProntoQA"),
269
+ (r"^logical[_-]?deduction[_-]", "LogicalDeduction"),
270
+ (r"^ar[_-]?lsat[_-]", "AR-LSAT"),
271
+ ):
272
+ if re.match(prefix, identifier, re.IGNORECASE):
273
+ return hint
274
+ return parent
275
+
276
+
277
+ def infer_schema(dataset: Dataset) -> Schema:
278
+ first = dataset.rows[0]
279
+ if {"premise", "hypothesis", "label"} <= first.keys():
280
+ kind, fields, label_field = "nli", ("premise", "hypothesis"), "label"
281
+ elif {"context", "question", "answer"} <= first.keys():
282
+ kind, fields, label_field = "qa", ("context", "question"), "answer"
283
+ elif {"text", "label"} <= first.keys():
284
+ kind, fields, label_field = "text", ("text",), "label"
285
+ else:
286
+ raise DatasetError(
287
+ f"{dataset.path}: records need premise/hypothesis/label, "
288
+ "context/question/answer, or text/label"
289
+ )
290
+
291
+ seen: list[str] = []
292
+ for index, row in enumerate(dataset.rows):
293
+ for field in (*fields, label_field):
294
+ if field not in row:
295
+ raise DatasetError(f"{dataset.path}: record {index} lacks {field!r}")
296
+ if not all(isinstance(row[field], str) and row[field].strip() for field in fields):
297
+ raise DatasetError(f"{dataset.path}: record {index} has an empty text field")
298
+ if row[label_field] is None or str(row[label_field]).strip() == "":
299
+ raise DatasetError(f"{dataset.path}: record {index} has an empty label")
300
+ if "options" in row and row["options"] is not None:
301
+ if not isinstance(row["options"], list) or not option_map(row["options"]):
302
+ raise DatasetError(
303
+ f"{dataset.path}: record {index} has invalid labeled options"
304
+ )
305
+ answer = str(row[label_field]).strip().upper()
306
+ if kind == "qa" and answer not in option_map(row["options"]):
307
+ raise DatasetError(
308
+ f"{dataset.path}: record {index} answer {answer!r} "
309
+ "is not an option key"
310
+ )
311
+ label = str(row[label_field])
312
+ if label not in seen:
313
+ seen.append(label)
314
+ return Schema(
315
+ kind=kind,
316
+ text_fields=fields,
317
+ label_field=label_field,
318
+ observed_labels=tuple(seen),
319
+ dataset_hint=_dataset_hint(dataset),
320
+ )
321
+
322
+
323
+ def sample_text(row: dict[str, Any], schema: Schema) -> str:
324
+ parts = [str(row[field]) for field in schema.text_fields]
325
+ if isinstance(row.get("options"), list):
326
+ parts.extend(str(value) for value in row["options"])
327
+ return "\n".join(parts)
328
+
329
+
330
+ def text_signature(row: dict[str, Any], schema: Schema) -> str:
331
+ return _SPACE_RE.sub(" ", sample_text(row, schema).strip()).casefold()
332
+
333
+
334
+ def cast_label_like(label: str, original: Any) -> Any:
335
+ """Keep numeric and Boolean label types from the source dataset."""
336
+ if isinstance(original, bool):
337
+ if label.lower() in {"true", "false"}:
338
+ return label.lower() == "true"
339
+ raise DatasetError(f"Cannot convert label {label!r} to bool")
340
+ if isinstance(original, int):
341
+ try:
342
+ return int(label)
343
+ except ValueError as exc:
344
+ raise DatasetError(f"Cannot convert label {label!r} to int") from exc
345
+ if isinstance(original, float):
346
+ try:
347
+ return float(label)
348
+ except ValueError as exc:
349
+ raise DatasetError(f"Cannot convert label {label!r} to float") from exc
350
+ return label
351
+
352
+
353
+ def build_candidate(
354
+ source: dict[str, Any],
355
+ generated: Any,
356
+ schema: Schema,
357
+ label: str,
358
+ ) -> dict[str, Any]:
359
+ if isinstance(generated, dict):
360
+ generated = generated.get("sample", generated.get("data", generated))
361
+ if not isinstance(generated, dict):
362
+ raise DatasetError("Agent returned no sample object")
363
+
364
+ candidate = dict(source)
365
+ for field in schema.text_fields:
366
+ value = generated.get(field, source[field])
367
+ if not isinstance(value, str) or not value.strip():
368
+ raise DatasetError(f"Generated {field!r} is empty or not text")
369
+ candidate[field] = value.strip()
370
+
371
+ if "options" in source and source["options"] is not None:
372
+ options = generated.get("options", source["options"])
373
+ if not isinstance(options, list) or len(options) != len(source["options"]):
374
+ raise DatasetError("Generated options changed the number of options")
375
+ original_keys = tuple(option_map(source["options"]))
376
+ generated_keys = tuple(option_map(options))
377
+ if not generated_keys or generated_keys != original_keys:
378
+ raise DatasetError("Generated options changed their keys or order")
379
+ candidate["options"] = options
380
+ elif "options" in generated:
381
+ raise DatasetError("Generated options were added to an option-free sample")
382
+
383
+ if text_signature(candidate, schema) == text_signature(source, schema):
384
+ raise DatasetError("Generated sample is identical to its source")
385
+ candidate[schema.label_field] = cast_label_like(label, source[schema.label_field])
386
+ return candidate
387
+
388
+
389
+ def assign_identity(
390
+ source: dict[str, Any],
391
+ candidate: dict[str, Any],
392
+ serial: int,
393
+ next_numeric_idx: int | None,
394
+ ) -> int | None:
395
+ if "id" in source:
396
+ candidate["id"] = f"{source['id']}__logical_da_{serial}"
397
+ if "idx" in source:
398
+ if isinstance(source["idx"], int) and next_numeric_idx is not None:
399
+ candidate["idx"] = next_numeric_idx
400
+ next_numeric_idx += 1
401
+ else:
402
+ candidate["idx"] = f"{source['idx']}__logical_da_{serial}"
403
+ return next_numeric_idx
404
+
405
+
406
+ def next_idx(rows: list[dict[str, Any]]) -> int | None:
407
+ values = [row["idx"] for row in rows if isinstance(row.get("idx"), int)]
408
+ return max(values) + 1 if values else None
409
+
410
+
411
+ def write_dataset(path: Path, rows: list[dict[str, Any]], fmt: str) -> None:
412
+ path.parent.mkdir(parents=True, exist_ok=True)
413
+ temporary = path.with_name(path.name + ".tmp")
414
+ with temporary.open("w", encoding="utf-8") as handle:
415
+ if fmt == "json":
416
+ json.dump(rows, handle, ensure_ascii=False, indent=2)
417
+ handle.write("\n")
418
+ elif fmt == "jsonl":
419
+ for row in rows:
420
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
421
+ else:
422
+ raise ValueError(f"Unknown output format: {fmt}")
423
+ temporary.replace(path)