touchstone-compute 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.
touchstone/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Touchstone — deterministic, verifiable text/code/measurement compute for AI agents.
2
+
3
+ The open core: every function is deterministic (same input → same bytes) and reproduces
4
+ a named published authority, so any answer can be re-executed and checked rather than
5
+ trusted. The hosted suite (touchstone.locomot.io) adds a drand-anchored trust layer and
6
+ signed pay-per-call receipts over x402.
7
+
8
+ from touchstone import units, citation, textdiff, proofreading, readability, syntax
9
+
10
+ units.compute(1, "nautical_mile", "meter")
11
+ citation.compute("apa", "book", authors="Anderson, Benedict",
12
+ title="Imagined Communities", year=1983, publisher="Verso")
13
+
14
+ Trust by re-execution, not reputation. github.com/savecharlie/touchstone
15
+ """
16
+ from touchstone import units, citation, textdiff, proofreading, readability, syntax # noqa: F401
17
+
18
+ __version__ = "0.1.0"
19
+ __all__ = ["units", "citation", "textdiff", "proofreading", "readability", "syntax"]
touchstone/citation.py ADDED
@@ -0,0 +1,532 @@
1
+ """citecore — deterministic citation formatter (APA 7, MLA 9, Chicago 17 author-date, BibTeX).
2
+
3
+ The Touchstone thesis applied to writing: an LLM drafts the prose, WE assemble the
4
+ citation correctly — element order, punctuation, italics, author-name inversion, the
5
+ "& vs and" join, et-al. thresholds, date formatting — all of it deterministic and
6
+ reproducible against published style-manual examples.
7
+
8
+ HONEST BOUNDARY (this is the brand): we do NOT guess letter-case. Sentence-case vs
9
+ title-case depends on proper-noun detection, which is not deterministic, so titles and
10
+ container names are taken VERBATIM — the caller supplies the case the style wants. What
11
+ we guarantee is the assembly: every comma, period, italic, and "et al." in the right
12
+ place, every time, byte-for-byte against the authority. Pass `title_case=True` to apply
13
+ the mechanical headline-case algorithm (Chicago/APA title rules) when you want it.
14
+
15
+ Output renders the reference in three forms — `markdown` (italics as *...*), `html`
16
+ (italics as <i>...</i>), and `plain` (no markers) — plus the `intext` citation.
17
+
18
+ Authority-reproduced (see tests): APA Style / Scribbr, MLA Handbook 9 / SDSU LibGuide,
19
+ The Chicago Manual of Style 17 author-date / Scribbr.
20
+
21
+ Iris (Opus 4.8, 1M) + Ivy, Jun 29 2026.
22
+ """
23
+ import re
24
+
25
+ STYLES = ("apa", "mla", "chicago", "bibtex")
26
+ TYPES = ("article", "book", "chapter", "web")
27
+
28
+ # months MLA abbreviates (everything except May/June/July, which stay whole)
29
+ _MLA_MONTH = {
30
+ "january": "Jan.", "february": "Feb.", "march": "Mar.", "april": "Apr.",
31
+ "may": "May", "june": "June", "july": "July", "august": "Aug.",
32
+ "september": "Sept.", "october": "Oct.", "november": "Nov.", "december": "Dec.",
33
+ }
34
+ # words left lowercase in headline (title) case unless first/last
35
+ _LOWER = {"a", "an", "the", "and", "but", "or", "nor", "for", "so", "yet",
36
+ "as", "at", "by", "in", "of", "off", "on", "per", "to", "up", "via",
37
+ "vs", "with", "from", "into", "onto", "over"}
38
+
39
+
40
+ # ---------------------------------------------------------------- helpers
41
+ def _norm_authors(authors):
42
+ """Accept list of {family, given} dicts OR 'Family, Given' strings -> list of dicts."""
43
+ if authors is None:
44
+ return []
45
+ if isinstance(authors, dict):
46
+ authors = [authors]
47
+ if isinstance(authors, str):
48
+ # 'Family, Given; Family2, Given2' OR a single 'Family, Given'
49
+ parts = [a.strip() for a in re.split(r"\s*;\s*", authors) if a.strip()]
50
+ authors = parts
51
+ out = []
52
+ for a in authors:
53
+ if isinstance(a, dict):
54
+ fam = (a.get("family") or a.get("last") or "").strip()
55
+ giv = (a.get("given") or a.get("first") or "").strip()
56
+ else:
57
+ s = str(a).strip()
58
+ if "," in s:
59
+ fam, giv = [x.strip() for x in s.split(",", 1)]
60
+ else: # 'Given Family' -> split on last space
61
+ bits = s.rsplit(" ", 1)
62
+ if len(bits) == 2:
63
+ giv, fam = bits[0].strip(), bits[1].strip()
64
+ else:
65
+ fam, giv = s, ""
66
+ if fam or giv:
67
+ out.append({"family": fam, "given": giv})
68
+ return out
69
+
70
+
71
+ def _initials(given):
72
+ """'Paul D.' -> 'P. D.' ; 'Jean-Paul' -> 'J.-P.' (APA style)."""
73
+ toks = [t for t in given.split() if t]
74
+ out = []
75
+ for t in toks:
76
+ parts = t.split("-")
77
+ out.append("-".join((p[0].upper() + ".") if p else "" for p in parts))
78
+ return " ".join(out)
79
+
80
+
81
+ def _title_case(s):
82
+ """Mechanical headline (title) case per Chicago/APA: capitalize first & last word
83
+ and every word except short articles/conjunctions/prepositions in _LOWER."""
84
+ words = s.split()
85
+ n = len(words)
86
+ out = []
87
+ for i, w in enumerate(words):
88
+ bare = re.sub(r"[^A-Za-z]", "", w).lower()
89
+ if i == 0 or i == n - 1 or bare not in _LOWER:
90
+ # preserve all-caps acronyms; otherwise capitalize first letter
91
+ out.append(w if w[:1].isupper() and w[1:2].isupper() else _cap_first(w))
92
+ else:
93
+ out.append(w.lower())
94
+ return " ".join(out)
95
+
96
+
97
+ def _cap_first(w):
98
+ for i, ch in enumerate(w):
99
+ if ch.isalpha():
100
+ return w[:i] + ch.upper() + w[i + 1:]
101
+ return w
102
+
103
+
104
+ def _pages(p):
105
+ """Normalize a numeric range to an en-dash; otherwise verbatim."""
106
+ if not p:
107
+ return ""
108
+ p = str(p).strip()
109
+ m = re.match(r"^(\d+)\s*[-–—]\s*(\d+)$", p)
110
+ return f"{m.group(1)}–{m.group(2)}" if m else p
111
+
112
+
113
+ def _period(s):
114
+ """Append a period unless the string already ends in sentence punctuation."""
115
+ s = s.rstrip()
116
+ return s if s[-1:] in ".!?" else s + "."
117
+
118
+
119
+ def _quote(s):
120
+ """Quote a title, putting its terminal period inside the quotes (MLA/Chicago)."""
121
+ s = s.rstrip()
122
+ return f'"{s}"' if s[-1:] in ".!?" else f'"{s}."'
123
+
124
+
125
+ def _oxford(items, conj="and"):
126
+ """['a'] -> 'a'; ['a','b'] -> 'a and b'; ['a','b','c'] -> 'a, b, and c'."""
127
+ items = [i for i in items if i]
128
+ if not items:
129
+ return ""
130
+ if len(items) == 1:
131
+ return items[0]
132
+ if len(items) == 2:
133
+ return f"{items[0]} {conj} {items[1]}"
134
+ return ", ".join(items[:-1]) + f", {conj} {items[-1]}"
135
+
136
+
137
+ # ---- author renderers per style (reference list) ----
138
+ def _auth_apa(au):
139
+ """Anderson, B. + '..., & ...' ; 21+ -> first 19, …, last."""
140
+ names = [f"{a['family']}, {_initials(a['given'])}".rstrip(", ").rstrip()
141
+ for a in au]
142
+ if not names:
143
+ return ""
144
+ if len(names) == 1:
145
+ return names[0]
146
+ if len(names) <= 20:
147
+ return ", ".join(names[:-1]) + ", & " + names[-1]
148
+ return ", ".join(names[:19]) + ", … " + names[-1]
149
+
150
+
151
+ def _auth_mla(au):
152
+ """Last, First. / two: 'Last, First, and First Last' / 3+: 'Last, First, et al.'"""
153
+ if not au:
154
+ return ""
155
+ first = f"{au[0]['family']}, {au[0]['given']}".rstrip(", ").rstrip()
156
+ if len(au) == 1:
157
+ return first
158
+ if len(au) == 2:
159
+ return f"{first}, and {au[1]['given']} {au[1]['family']}".replace(" ", " ").strip()
160
+ return f"{first}, et al."
161
+
162
+
163
+ def _auth_chicago(au):
164
+ """First inverted, rest 'First Last'. Reference list always uses the comma before
165
+ 'and' (even for two authors, since the first name is inverted). Lists all names."""
166
+ if not au:
167
+ return ""
168
+ first = f"{au[0]['family']}, {au[0]['given']}".rstrip(", ").rstrip()
169
+ if len(au) == 1:
170
+ return first
171
+ rest = [f"{a['given']} {a['family']}".strip() for a in au[1:]]
172
+ if len(rest) == 1:
173
+ return f"{first}, and {rest[0]}"
174
+ return f"{first}, " + ", ".join(rest[:-1]) + f", and {rest[-1]}"
175
+
176
+
177
+ def _auth_bibtex(au):
178
+ return " and ".join(f"{a['family']}, {a['given']}".rstrip(", ").rstrip() for a in au)
179
+
180
+
181
+ def _editors(eds, style):
182
+ eds = _norm_authors(eds)
183
+ if not eds:
184
+ return ""
185
+ if style == "apa": # 'S. Malpas & P. Wake'
186
+ names = [f"{_initials(e['given'])} {e['family']}".strip() for e in eds]
187
+ if len(names) == 1:
188
+ return names[0]
189
+ return " & ".join([", ".join(names[:-1]), names[-1]]) if len(names) > 2 \
190
+ else " & ".join(names)
191
+ # mla / chicago: 'First Last' joined by Oxford and
192
+ names = [f"{e['given']} {e['family']}".strip() for e in eds]
193
+ return _oxford(names, "and")
194
+
195
+
196
+ def _intext_names(au, style):
197
+ fams = [a["family"] for a in au]
198
+ if not fams:
199
+ return ""
200
+ if style == "apa":
201
+ if len(fams) == 1:
202
+ return fams[0]
203
+ if len(fams) == 2:
204
+ return f"{fams[0]} & {fams[1]}"
205
+ return f"{fams[0]} et al."
206
+ if style == "mla":
207
+ if len(fams) == 1:
208
+ return fams[0]
209
+ if len(fams) == 2:
210
+ return f"{fams[0]} and {fams[1]}"
211
+ return f"{fams[0]} et al."
212
+ # chicago author-date
213
+ if len(fams) == 1:
214
+ return fams[0]
215
+ if len(fams) <= 3:
216
+ return _oxford(fams, "and")
217
+ return f"{fams[0]} et al."
218
+
219
+
220
+ # ---------------------------------------------------------------- renderers
221
+ # Each returns a list of segments: ("t", text) plain or ("i", text) italic.
222
+ def _seg_join(segs):
223
+ return segs
224
+
225
+
226
+ def _render(segs, mode):
227
+ out = []
228
+ for kind, txt in segs:
229
+ if kind == "i":
230
+ out.append({"markdown": f"*{txt}*", "html": f"<i>{txt}</i>",
231
+ "plain": txt}[mode])
232
+ else:
233
+ out.append(txt)
234
+ return "".join(out)
235
+
236
+
237
+ def _t(s):
238
+ return ("t", s)
239
+
240
+
241
+ def _i(s):
242
+ return ("i", s)
243
+
244
+
245
+ def _apa(typ, f, au):
246
+ yr = f.get("year") or "n.d."
247
+ title = f["_title"]
248
+ cont = f.get("container", "")
249
+ segs = []
250
+ a = _auth_apa(au)
251
+ if a:
252
+ segs.append(_t(f"{_period(a)} "))
253
+ # date
254
+ if typ == "web" and (f.get("month") or f.get("day")):
255
+ d = str(yr)
256
+ if f.get("month"):
257
+ d += f", {f['month']}"
258
+ if f.get("day"):
259
+ d += f" {f['day']}"
260
+ segs.append(_t(f"({d}). "))
261
+ else:
262
+ segs.append(_t(f"({yr}). "))
263
+ if typ == "article":
264
+ segs += [_t(f"{_period(title)} "), _i(cont)]
265
+ vol = f.get("volume", "")
266
+ if vol:
267
+ segs.append(_t(", "))
268
+ segs.append(_i(str(vol)))
269
+ if f.get("issue"):
270
+ segs.append(_t(f"({f['issue']})"))
271
+ if f.get("pages"):
272
+ segs.append(_t(f", {_pages(f['pages'])}"))
273
+ segs.append(_t("."))
274
+ elif typ == "book":
275
+ segs += [_i(title), _t(". ")]
276
+ if f.get("publisher"):
277
+ segs.append(_t(f"{f['publisher']}."))
278
+ elif typ == "chapter":
279
+ segs.append(_t(f"{_period(title)} In {_editors(f.get('editors'), 'apa')} "
280
+ f"({'Eds.' if len(_norm_authors(f.get('editors'))) > 1 else 'Ed.'}), "))
281
+ segs.append(_i(f.get("book_title", cont)))
282
+ if f.get("pages"):
283
+ segs.append(_t(f" (pp. {_pages(f['pages'])})"))
284
+ segs.append(_t(". "))
285
+ if f.get("publisher"):
286
+ segs.append(_t(f"{f['publisher']}."))
287
+ elif typ == "web":
288
+ segs += [_t(f"{_period(title)} "), _i(cont), _t(".")]
289
+ # trailing doi / url
290
+ link = _apa_link(f)
291
+ if link:
292
+ segs.append(_t(f" {link}"))
293
+ return segs
294
+
295
+
296
+ def _apa_link(f):
297
+ if f.get("doi"):
298
+ d = f["doi"]
299
+ return d if d.startswith("http") else f"https://doi.org/{d}"
300
+ return f.get("url", "")
301
+
302
+
303
+ def _mla(typ, f, au):
304
+ title = f["_title"]
305
+ cont = f.get("container", "")
306
+ segs = []
307
+ a = _auth_mla(au)
308
+ if a:
309
+ segs.append(_t(f"{_period(a)} "))
310
+ if typ == "book":
311
+ segs += [_i(title), _t(". ")]
312
+ pub = f.get("publisher", "")
313
+ yr = f.get("year", "")
314
+ segs.append(_t(f"{pub}, {yr}." if pub else f"{yr}."))
315
+ return segs
316
+ # article / chapter / web all quote the piece title
317
+ segs.append(_t(f"{_quote(title)} "))
318
+ if typ == "chapter":
319
+ segs.append(_i(f.get("book_title", cont)))
320
+ bits = []
321
+ if f.get("editors"):
322
+ bits.append(f"edited by {_editors(f.get('editors'), 'mla')}")
323
+ if f.get("publisher"):
324
+ bits.append(f["publisher"])
325
+ if f.get("year"):
326
+ bits.append(str(f["year"]))
327
+ if f.get("pages"):
328
+ bits.append(f"pp. {_pages(f['pages'])}")
329
+ segs.append(_t(", " + ", ".join(bits) + "." if bits else "."))
330
+ return segs
331
+ # article / web
332
+ segs.append(_i(cont))
333
+ bits = []
334
+ if typ == "article":
335
+ if f.get("volume"):
336
+ bits.append(f"vol. {f['volume']}")
337
+ if f.get("issue"):
338
+ bits.append(f"no. {f['issue']}")
339
+ if f.get("year"):
340
+ bits.append(str(f["year"]))
341
+ if f.get("pages"):
342
+ bits.append(f"pp. {_pages(f['pages'])}")
343
+ else: # web
344
+ if f.get("day") or f.get("month") or f.get("year"):
345
+ bits.append(_mla_date(f))
346
+ tail = ", " + ", ".join(b for b in bits if b) if bits else ""
347
+ link = f.get("url") or (("https://doi.org/" + f["doi"]) if f.get("doi") else "")
348
+ if link:
349
+ tail += f", {link}"
350
+ segs.append(_t(tail + "."))
351
+ return segs
352
+
353
+
354
+ def _mla_date(f):
355
+ parts = []
356
+ if f.get("day"):
357
+ parts.append(str(f["day"]))
358
+ if f.get("month"):
359
+ parts.append(_MLA_MONTH.get(str(f["month"]).lower(), str(f["month"])))
360
+ if f.get("year"):
361
+ parts.append(str(f["year"]))
362
+ return " ".join(parts)
363
+
364
+
365
+ def _chicago(typ, f, au):
366
+ title = f["_title"]
367
+ cont = f.get("container", "")
368
+ yr = f.get("year") or "n.d."
369
+ segs = []
370
+ a = _auth_chicago(au)
371
+ if a:
372
+ segs.append(_t(f"{_period(a)} {yr}. "))
373
+ else:
374
+ segs.append(_t(f"{yr}. "))
375
+ if typ == "book":
376
+ segs.append(_i(title))
377
+ segs.append(_t(". "))
378
+ place, pub = f.get("place", ""), f.get("publisher", "")
379
+ if place and pub:
380
+ segs.append(_t(f"{place}: {pub}."))
381
+ elif pub:
382
+ segs.append(_t(f"{pub}."))
383
+ return segs
384
+ segs.append(_t(f"{_quote(title)} "))
385
+ if typ == "chapter":
386
+ segs += [_t("In "), _i(f.get("book_title", cont))]
387
+ bits = []
388
+ if f.get("editors"):
389
+ bits.append(f"edited by {_editors(f.get('editors'), 'chicago')}")
390
+ if f.get("pages"):
391
+ bits.append(_pages(f["pages"]))
392
+ segs.append(_t(", " + ", ".join(bits) if bits else ""))
393
+ segs.append(_t(". "))
394
+ place, pub = f.get("place", ""), f.get("publisher", "")
395
+ if place and pub:
396
+ segs.append(_t(f"{place}: {pub}."))
397
+ elif pub:
398
+ segs.append(_t(f"{pub}."))
399
+ return segs
400
+ if typ == "article":
401
+ segs.append(_i(cont))
402
+ tail = ""
403
+ if f.get("volume"):
404
+ tail += f" {f['volume']}"
405
+ if f.get("issue"):
406
+ tail += f", no. {f['issue']}"
407
+ if f.get("month"):
408
+ tail += f" ({f['month']})"
409
+ if f.get("pages"):
410
+ tail += f": {_pages(f['pages'])}"
411
+ tail += "."
412
+ segs.append(_t(tail))
413
+ if f.get("doi"):
414
+ d = f["doi"]
415
+ segs.append(_t(f" {d if d.startswith('http') else 'https://doi.org/' + d}."))
416
+ elif f.get("url"):
417
+ segs.append(_t(f" {f['url']}."))
418
+ return segs
419
+ # web
420
+ if cont:
421
+ segs.append(_t(f"{cont}. "))
422
+ if f.get("url"):
423
+ segs.append(_t(f"{f['url']}."))
424
+ return segs
425
+
426
+
427
+ def _bibtex(typ, f, au):
428
+ bt = {"article": "article", "book": "book", "chapter": "incollection",
429
+ "web": "misc"}[typ]
430
+ key = (au[0]["family"].lower().replace(" ", "") if au else "ref") + str(f.get("year") or "")
431
+ key = re.sub(r"[^a-z0-9]", "", key)
432
+ fields = [("author", _auth_bibtex(au))]
433
+ if typ == "chapter":
434
+ fields.append(("title", f["_title"]))
435
+ fields.append(("booktitle", f.get("book_title", f.get("container", ""))))
436
+ if f.get("editors"):
437
+ fields.append(("editor", _auth_bibtex(_norm_authors(f.get("editors")))))
438
+ else:
439
+ fields.append(("title", f["_title"]))
440
+ if typ == "article":
441
+ fields.append(("journal", f.get("container", "")))
442
+ for k in ("volume", "number_from_issue"):
443
+ pass
444
+ if f.get("volume"):
445
+ fields.append(("volume", str(f["volume"])))
446
+ if f.get("issue"):
447
+ fields.append(("number", str(f["issue"])))
448
+ if typ in ("book", "chapter"):
449
+ if f.get("publisher"):
450
+ fields.append(("publisher", f["publisher"]))
451
+ if f.get("place"):
452
+ fields.append(("address", f["place"]))
453
+ if f.get("pages"):
454
+ fields.append(("pages", _pages(f["pages"]).replace("–", "--")))
455
+ if f.get("year"):
456
+ fields.append(("year", str(f["year"])))
457
+ if f.get("doi"):
458
+ fields.append(("doi", f["doi"]))
459
+ if f.get("url"):
460
+ fields.append(("url", f["url"]))
461
+ body = ",\n".join(f" {k} = {{{v}}}" for k, v in fields if v)
462
+ return f"@{bt}{{{key},\n{body}\n}}"
463
+
464
+
465
+ def _intext(style, typ, f, au):
466
+ names = _intext_names(au, style)
467
+ yr = f.get("year") or "n.d."
468
+ pg = f.get("locator") or "" # page/paragraph for in-text
469
+ if not names: # no author -> short title
470
+ names = f["_title"].split()
471
+ names = " ".join(names[:4])
472
+ names = f'"{names}"'
473
+ if style == "apa":
474
+ s = f"({names}, {yr}"
475
+ if pg:
476
+ s += f", p. {pg}" if str(pg).isdigit() else f", {pg}"
477
+ return s + ")"
478
+ if style == "mla":
479
+ return f"({names} {pg})".replace(" )", ")") if pg else f"({names})"
480
+ if style == "chicago":
481
+ s = f"({names} {yr}"
482
+ if pg:
483
+ s += f", {pg}"
484
+ return s + ")"
485
+ return "" # bibtex has no in-text
486
+
487
+
488
+ # ---------------------------------------------------------------- public
489
+ def compute(style, source_type=None, type=None, authors=None, title=None,
490
+ title_case=False, **f):
491
+ """Format a citation. style in STYLES; source_type in TYPES.
492
+
493
+ Fields (all optional except title): authors, title, year, container, volume,
494
+ issue, pages, publisher, place, doi, url, edition, editors, book_title,
495
+ month, day, locator (page/para for in-text).
496
+ """
497
+ style = (style or "").strip().lower()
498
+ typ = (source_type or type or "").strip().lower()
499
+ if style not in STYLES:
500
+ raise ValueError(f"style must be one of {STYLES}")
501
+ if typ not in TYPES:
502
+ raise ValueError(f"source_type must be one of {TYPES}")
503
+ if not title or not str(title).strip():
504
+ raise ValueError("title is required")
505
+ au = _norm_authors(authors)
506
+ f = dict(f)
507
+ f["authors"] = authors
508
+ if "editors" in f:
509
+ pass
510
+ t = str(title).strip()
511
+ f["_title"] = _title_case(t) if title_case else t
512
+ if f.get("book_title") and title_case:
513
+ f["book_title"] = _title_case(f["book_title"])
514
+
515
+ builder = {"apa": _apa, "mla": _mla, "chicago": _chicago}.get(style)
516
+ if style == "bibtex":
517
+ bib = _bibtex(typ, f, au)
518
+ return {"style": "bibtex", "source_type": typ,
519
+ "reference": {"markdown": bib, "html": bib, "plain": bib},
520
+ "bibtex": bib, "intext": None}
521
+ segs = builder(typ, f, au)
522
+ ref = {m: _render(segs, m) for m in ("markdown", "html", "plain")}
523
+ return {"style": style, "source_type": typ, "reference": ref,
524
+ "intext": _intext(style, typ, f, au)}
525
+
526
+
527
+ def sample_output():
528
+ return compute("apa", "article",
529
+ authors=[{"family": "Mounier-Kuhn", "given": "Pierre"}],
530
+ title="Computer science in French universities: Early entrants and latecomers",
531
+ year=2012, container="Information & Culture: A Journal of History",
532
+ volume=47, issue=4, pages="414-456", doi="10.7560/IC47402")