Core ML
legal
citation-verification
hallucination-detection
apple-neural-engine
hand-written-weights
alexwengg commited on
Commit
42b4a6a
·
verified ·
1 Parent(s): 63d70e1

CiteNet v1.5: legal-citation checker with hand-written weights (Core ML)

Browse files
Files changed (49) hide show
  1. README.md +90 -0
  2. bench/CiteScan.swift +323 -0
  3. bench/bench.swift +79 -0
  4. bench/firehose.swift +78 -0
  5. bench/scanparity.swift +31 -0
  6. bench/stream/main.swift +213 -0
  7. citenet/__init__.py +0 -0
  8. citenet/__pycache__/__init__.cpython-312.pyc +0 -0
  9. citenet/__pycache__/spec.cpython-312.pyc +0 -0
  10. citenet/__pycache__/text.cpython-312.pyc +0 -0
  11. citenet/net.py +391 -0
  12. citenet/spec.py +357 -0
  13. citenet/text.py +77 -0
  14. demo/index.html +186 -0
  15. demo/inspect.html +368 -0
  16. demo/live.html +165 -0
  17. demo/sample_brief.txt +5 -0
  18. demo/sample_mata.txt +10 -0
  19. demo/server.py +568 -0
  20. demo/wall.html +311 -0
  21. models/CiteNet_b1.mlpackage/Data/com.apple.CoreML/model.mlmodel +3 -0
  22. models/CiteNet_b1.mlpackage/Data/com.apple.CoreML/weights/weight.bin +3 -0
  23. models/CiteNet_b1.mlpackage/Manifest.json +18 -0
  24. models/CiteNet_b32.mlpackage/Data/com.apple.CoreML/model.mlmodel +3 -0
  25. models/CiteNet_b32.mlpackage/Data/com.apple.CoreML/weights/weight.bin +3 -0
  26. models/CiteNet_b32.mlpackage/Manifest.json +18 -0
  27. models/CiteNet_b512.mlpackage/Data/com.apple.CoreML/model.mlmodel +3 -0
  28. models/CiteNet_b512.mlpackage/Data/com.apple.CoreML/weights/weight.bin +3 -0
  29. models/CiteNet_b512.mlpackage/Manifest.json +18 -0
  30. models/CiteNet_lean_b512.mlpackage/Data/com.apple.CoreML/model.mlmodel +3 -0
  31. models/CiteNet_lean_b512.mlpackage/Data/com.apple.CoreML/weights/weight.bin +3 -0
  32. models/CiteNet_lean_b512.mlpackage/Manifest.json +18 -0
  33. models/CiteNet_lean_b8192.mlpackage/Data/com.apple.CoreML/model.mlmodel +3 -0
  34. models/CiteNet_lean_b8192.mlpackage/Data/com.apple.CoreML/weights/weight.bin +3 -0
  35. models/CiteNet_lean_b8192.mlpackage/Manifest.json +18 -0
  36. models/meta.json +1 -0
  37. models/weights.npz +3 -0
  38. results/aux_train_v1.5.txt +18 -0
  39. results/bench_swift.txt +17 -0
  40. results/eval_v1.0_blind.txt +29 -0
  41. results/eval_v1.5.txt +17 -0
  42. results/firehose.txt +4 -0
  43. results/train_baseline_v1.1.txt +7 -0
  44. scripts/cold_extract.py +48 -0
  45. scripts/convert.py +85 -0
  46. scripts/evaluate.py +44 -0
  47. scripts/lepc.py +34 -0
  48. scripts/scan_parity.py +29 -0
  49. scripts/train_baseline.py +66 -0
README.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - coreml
5
+ - legal
6
+ - citation-verification
7
+ - hallucination-detection
8
+ - apple-neural-engine
9
+ - hand-written-weights
10
+ datasets:
11
+ - ai-law-society-lab/Legal_Phantom_Citation
12
+ - harvard-lil/cold-cases
13
+ ---
14
+
15
+ # CiteNet: a legal-citation checker whose weights were written by hand
16
+
17
+ A 397,250-parameter network (13,871 nonzero) that flags legal citations that cannot exist: an invented reporter series (`F.5th`, `S.W.4th`), a court the reporter never prints (`88 F. Supp. 3d 412 (5th Cir. 2015)`), a state the regional reporter doesn't cover, or a year that doesn't fit the volume (`912 F.3d 455 (9th Cir. 1998)`; vol 912 is from 2018).
18
+
19
+ **No training.** Claude (Anthropic) wrote `citenet/spec.py`: the reporters and their real series, which courts each reporter prints, the West regional map, and approximate volume→year timelines. It also wrote `citenet/net.py`, which compiles each entry into specific weights:
20
+
21
+ - a conv filter that fires only when the characters spell `<digit> F.3d<space>`
22
+ - digit decoders with weight k·10^i on digit k
23
+ - a shared ReLU "volume thermometer" that every volume→year curve reads from
24
+ - AND gates like `relu(8·[federal appellate reporter] + [state court] − 8)`
25
+
26
+ Every nonzero weight has a name, and there was no gradient step.
27
+
28
+ ## Results (LePhantomCite, held-out eval split, citation level)
29
+
30
+ [ai-law-society-lab/Legal_Phantom_Citation](https://huggingface.co/datasets/ai-law-society-lab/Legal_Phantom_Citation) (Liu, Stammbach & Henderson 2026, CC BY 4.0). The network was designed using only the `aux_train` split. `eval` was scored once with frozen weights.
31
+
32
+ | | phantom citations caught | real citations flagged |
33
+ |---|---|---|
34
+ | v1.0, first and only blind run | 31 / 31 | 3 / 983 |
35
+ | v1.5 (this release; later fixes were not blind) | 31 / 31 | 1 / 983 |
36
+
37
+ - **The remaining flag** is `652 A.2d 634 (D.C. Cir. 1995)`. A.2d never prints D.C. Circuit opinions, so it is likely an error in the source brief.
38
+ - **Out of scope:** other hallucination types (case-name mismatch, wrong pincite, misquote) need the case text. They are flagged 2 / 276 times.
39
+ - **Hand-written vs trained:** trained from scratch, the same architecture (v1.1) ties on this benchmark, with 0 / 983 false flags. It caught 1 of 5 planted fakes of kinds the training data never shows, against 5 / 5 for the hand-written net. That 5-fake set is tiny and I wrote it, so treat it as illustrative. A naive fine-tune starting from the hand-written weights made it worse. See `results/`.
40
+
41
+ **Limits.** A string-only network can tell that a citation *could* exist, not that it does. LePhantomCite's non-existent citations are structurally impossible, so it catches all of them. Real LLM fakes, such as the six citations ChatGPT invented in *Mata v. Avianca* (e.g. `925 F.3d 1339 (11th Cir. 2019)`), copy valid formats and pass. Use this as a fast first filter in front of a case-law lookup.
42
+
43
+ **Real case law.** On 3.30 GB of US court opinions (291,339 decisions, COLD Cases shard 0), 0.134% of citations are flagged. Most flags look like real errors in the opinions, e.g. `387 U.S. 1 (1887)` for In re Gault (1967). They are unverified, and some are the model's own mistakes.
44
+
45
+ ## Speed (Core ML, Apple M5 Pro, native Swift)
46
+
47
+ | | |
48
+ |---|---|
49
+ | 1 citation (`CiteNet_b1`, CPU) | ~50 µs |
50
+ | 32 citations (`CiteNet_b32`, Neural Engine) | 170 µs |
51
+ | bulk, Neural Engine (`CiteNet_lean_b512`) | ~430k citations/s |
52
+ | bulk, GPU (`CiteNet_lean_b8192`) | ~1.38M citations/s |
53
+ | Neural Engine + GPU together | **~1.82M citations/s** |
54
+ | raw text, end to end: native scanner + ANE + GPU | **3.30 GB of opinions in 2.3 s** (1.44 GB/s, 1.68M citations/s) |
55
+
56
+ Every build is fp16, and the labels match the fp32 PyTorch model on all 4,383 LePhantomCite citations. Fixed batch shapes matter: with `EnumeratedShapes`, Core ML ran everything on the CPU.
57
+
58
+ ## Files
59
+
60
+ - `models/`: `CiteNet_b{1,32,512}.mlpackage` output `p_fake`, the 7 `evidence` values and the named `concepts`. `CiteNet_lean_b{512,8192}.mlpackage` output only `p_fake` and `evidence`, for bulk work. `meta.json` names every neuron; `weights.npz` holds the raw weights.
61
+ - `citenet/`: the spec, the weight compiler and the tokenizer (`text.py`: citation extraction and normalization to 64 char codes).
62
+ - `bench/`:
63
+ - `CiteScan.swift`: a byte-level Swift port of the tokenizer, byte-identical to Python (`scripts/scan_parity.py`).
64
+ - `stream/main.swift`: the multithreaded raw-text pipeline.
65
+ - `firehose.swift`, `bench.swift`: benchmarks.
66
+ - `demo/`: a local web demo (`python demo/server.py`, localhost:8025).
67
+ - a live checker
68
+ - `/wall`: a scrolling text wall that highlights citations as they arrive, with a planted-fakes mode that reports planted, caught and missed counts
69
+ - `/live`: the full-speed scan
70
+ - `/inspect`: the neuron and filter view
71
+ - `results/`: the v1.0 blind eval log, v1.5 eval logs, the training comparison and benchmark logs.
72
+
73
+ ## Use
74
+
75
+ ```python
76
+ import numpy as np, coremltools as ct
77
+ from citenet import text
78
+
79
+ m = ct.models.MLModel("models/CiteNet_b32.mlpackage")
80
+ cites = [c for _, _, c in text.extract("See Henley v. Northwind, 412 F.5th 1187 (2d Cir. 2023); "
81
+ "Bell Atl. Corp. v. Twombly, 550 U.S. 544, 570 (2007).")]
82
+ x = np.zeros((32, 1, 1, 64), np.float16)
83
+ x[:len(cites)] = text.encode(cites)
84
+ p = m.predict({"codes": x})["p_fake"][:len(cites), 0]
85
+ print(list(zip(cites, p.round(3)))) # F.5th -> ~0.99, Twombly -> ~0.02
86
+ ```
87
+
88
+ To rebuild every model from the spec: `python scripts/convert.py` (torch + coremltools). The demo server expects the models in `build/`.
89
+
90
+ Data: LePhantomCite (CC BY 4.0) and COLD Cases (CC0) are not included; download them from the Hub.
bench/CiteScan.swift ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Native citation scanner + encoder: a byte-level port of citenet/text.py
2
+ // (CITE_RE, normalize, encode). scanparity.swift checks it against Python.
3
+ import Foundation
4
+
5
+ let SEQ = 64
6
+ let MAXCHARS = 46
7
+
8
+ @inline(__always) func isDigit(_ b: UInt8) -> Bool { b >= 0x30 && b <= 0x39 }
9
+ @inline(__always) func isUpper(_ b: UInt8) -> Bool { b >= 0x41 && b <= 0x5A }
10
+ @inline(__always) func isLower(_ b: UInt8) -> Bool { b >= 0x61 && b <= 0x7A }
11
+ @inline(__always) func isAlpha(_ b: UInt8) -> Bool { isUpper(b) || isLower(b) }
12
+
13
+ struct Text {
14
+ let p: UnsafePointer<UInt8>
15
+ let n: Int
16
+ @inline(__always) subscript(_ i: Int) -> UInt8 { i < n ? p[i] : 0 }
17
+
18
+ /// Length in bytes of the whitespace character at i (Python str `\s`), or 0.
19
+ @inline(__always) func ws(_ i: Int) -> Int {
20
+ let b = self[i]
21
+ if b == 0x20 || (b >= 0x09 && b <= 0x0D) || (b >= 0x1C && b <= 0x1F) { return i < n ? 1 : 0 }
22
+ if b < 0x80 { return 0 }
23
+ let b1 = self[i + 1], b2 = self[i + 2]
24
+ if b == 0xC2 && (b1 == 0x85 || b1 == 0xA0) { return 2 }
25
+ if b == 0xE1 && b1 == 0x9A && b2 == 0x80 { return 3 }
26
+ if b == 0xE2 && b1 == 0x80 && ((b2 >= 0x80 && b2 <= 0x8A) || b2 == 0xA8 || b2 == 0xA9 || b2 == 0xAF) { return 3 }
27
+ if b == 0xE2 && b1 == 0x81 && b2 == 0x9F { return 3 }
28
+ if b == 0xE3 && b1 == 0x80 && b2 == 0x80 { return 3 }
29
+ return 0
30
+ }
31
+ @inline(__always) func skipWS(_ i: Int) -> Int { var j = i; while true { let w = ws(j); if w == 0 { return j }; j += w } }
32
+ @inline(__always) func isRSQ(_ i: Int) -> Bool { self[i] == 0xE2 && self[i + 1] == 0x80 && self[i + 2] == 0x99 }
33
+ @inline(__always) func isEnDash(_ i: Int) -> Bool { self[i] == 0xE2 && self[i + 1] == 0x80 && self[i + 2] == 0x93 }
34
+
35
+ /// Python `\w` for the character ending just before i (ASCII exact; non-ASCII letters count as word chars).
36
+ func wordBefore(_ i: Int) -> Bool {
37
+ if i <= 0 { return false }
38
+ let b = p[i - 1]
39
+ if b < 0x80 { return isAlpha(b) || isDigit(b) || b == 0x5F }
40
+ var s = i - 1
41
+ while s > 0 && (p[s] & 0xC0) == 0x80 { s -= 1 }
42
+ guard let sc = String(bytes: UnsafeBufferPointer(start: p + s, count: i - s), encoding: .utf8)?.unicodeScalars.first else { return false }
43
+ return sc.properties.isAlphabetic || sc.properties.numericType != nil
44
+ }
45
+ /// Python `\w` for the character starting at i.
46
+ func wordAt(_ i: Int) -> Bool {
47
+ if i >= n { return false }
48
+ let b = p[i]
49
+ if b < 0x80 { return isAlpha(b) || isDigit(b) || b == 0x5F }
50
+ var e = i + 1
51
+ while e < n && (p[e] & 0xC0) == 0x80 { e += 1 }
52
+ return wordBefore(e)
53
+ }
54
+
55
+ /// TOKEN = [A-Z][A-Za-z'’&]*\.? | \d+(?:d|th|st|nd|rd)\b -> end index or -1
56
+ @inline(__always) func token(_ i: Int) -> Int {
57
+ let b = self[i]
58
+ if isUpper(b) {
59
+ var q = i + 1
60
+ while q < n {
61
+ let c = p[q]
62
+ if isAlpha(c) || c == 0x27 || c == 0x26 { q += 1 } else if isRSQ(q) { q += 3 } else { break }
63
+ }
64
+ if self[q] == 0x2E { q += 1 }
65
+ return q
66
+ }
67
+ if isDigit(b) {
68
+ var q = i
69
+ while isDigit(self[q]) { q += 1 }
70
+ let c0 = self[q], c1 = self[q + 1]
71
+ var e = -1
72
+ if c0 == 0x64 { e = q + 1 } // d
73
+ else if (c0 == 0x74 && c1 == 0x68) || (c0 == 0x73 && c1 == 0x74) ||
74
+ (c0 == 0x6E && c1 == 0x64) || (c0 == 0x72 && c1 == 0x64) { e = q + 2 } // th st nd rd
75
+ return (e >= 0 && !wordAt(e)) ? e : -1
76
+ }
77
+ return -1
78
+ }
79
+
80
+ /// (?:at\s+)?\*?\d{1,8} -> end or -1
81
+ @inline(__always) func starDigits(_ s: Int, _ m: Int) -> Int {
82
+ var t = s
83
+ if self[t] == 0x2A { t += 1 }
84
+ let d0 = t
85
+ while isDigit(self[t]) && t - d0 < m { t += 1 }
86
+ return t > d0 ? t : -1
87
+ }
88
+ @inline(__always) func pageAt(_ i: Int, maxDigits: Int) -> Int {
89
+ if self[i] == 0x61 && self[i + 1] == 0x74 {
90
+ let k = skipWS(i + 2)
91
+ if k > i + 2 { let r = starDigits(k, maxDigits); if r >= 0 { return r } }
92
+ }
93
+ return starDigits(i, maxDigits)
94
+ }
95
+
96
+ /// One CITE_RE match starting at i (i must be at a digit). Returns (end, accept) or nil;
97
+ /// accept = the reporter has an uppercase letter and does not open with a STOP word.
98
+ func match(_ i: Int) -> (Int, Bool)? {
99
+ var j = i
100
+ while isDigit(self[j]) { j += 1 }
101
+ if j - i > 4 { return nil }
102
+ let k = skipWS(j)
103
+ if k == j { return nil }
104
+ // greedy maximal tokens, at most 5, separated by at most one whitespace char
105
+ var ends = [Int](); ends.reserveCapacity(5)
106
+ var upper = [Bool](); upper.reserveCapacity(5)
107
+ var t = k
108
+ while ends.count < 5 {
109
+ let e = token(t)
110
+ if e < 0 { break }
111
+ ends.append(e); upper.append(isUpper(self[t]) || (upper.last ?? false))
112
+ t = e + ws(e) // \s? : at most one whitespace char
113
+ }
114
+ if ends.isEmpty { return nil }
115
+ for c in stride(from: ends.count - 1, through: 0, by: -1) {
116
+ let e = ends[c]
117
+ let s = skipWS(e)
118
+ var pg = -1
119
+ if self[s] == 0x2C { // \s*,\s*at\s+ page ("976 F. 2d, at 540")
120
+ let q = skipWS(s + 1)
121
+ if self[q] == 0x61 && self[q + 1] == 0x74 {
122
+ let r = skipWS(q + 2)
123
+ if r > q + 2 { pg = starDigits(r, 8) }
124
+ }
125
+ } else if s > e { // \s+(?:at\s+)? page
126
+ pg = pageAt(s, maxDigits: 8)
127
+ }
128
+ if pg < 0 { continue }
129
+ var end = pg
130
+ // pincites: (?:\s*,\s*(?:at\s+)?\*?\d+(?:\s*[-–]\s*\*?\d+)?)*
131
+ while true {
132
+ var q = skipWS(end)
133
+ if self[q] != 0x2C { break }
134
+ q = skipWS(q + 1)
135
+ let d = pageAt(q, maxDigits: Int.max)
136
+ if d < 0 { break }
137
+ var e2 = d
138
+ var r = skipWS(d)
139
+ if self[r] == 0x2D || isEnDash(r) {
140
+ r = skipWS(r + (self[r] == 0x2D ? 1 : 3))
141
+ if self[r] == 0x2A { r += 1 }
142
+ let d0 = r
143
+ while isDigit(self[r]) { r += 1 }
144
+ if r > d0 { e2 = r }
145
+ }
146
+ end = e2
147
+ }
148
+ // paren: \s*\([^()]{0,60}?\d{4}\)
149
+ let po = skipWS(end)
150
+ if self[po] == 0x28 {
151
+ var q = po + 1, chars = 0
152
+ while q < n && p[q] != 0x28 && p[q] != 0x29 && chars <= 64 {
153
+ if (p[q] & 0xC0) != 0x80 { chars += 1 }
154
+ q += 1
155
+ }
156
+ if q < n && p[q] == 0x29 && chars >= 4 && chars - 4 <= 60 &&
157
+ isDigit(p[q - 1]) && isDigit(p[q - 2]) && isDigit(p[q - 3]) && isDigit(p[q - 4]) {
158
+ end = q + 1
159
+ }
160
+ }
161
+ let first = Array(UnsafeBufferPointer(start: p + k, count: ends[0] - k))
162
+ return (end, upper[c] && !STOP.contains(first))
163
+ }
164
+ return nil
165
+ }
166
+
167
+ /// finditer over [lo, hi): calls emit(start, end) for each accepted citation.
168
+ func scan(_ lo: Int, _ hi: Int, _ emit: (Int, Int) -> Void) {
169
+ var i = lo
170
+ while i < hi {
171
+ if isDigit(p[i]) && !wordBefore(i) {
172
+ if let (e, up) = match(i) {
173
+ if up { emit(i, e) }
174
+ i = e
175
+ continue
176
+ }
177
+ while i < hi && isDigit(p[i]) { i += 1 } // \b fails inside the rest of this run
178
+ continue
179
+ }
180
+ i += 1
181
+ }
182
+ }
183
+ }
184
+
185
+ /// citenet/text.py STOP: words that open a match but never a reporter
186
+ let STOP: Set<[UInt8]> = Set("""
187
+ No. Nos. No Id. Id Ibid. Section Sec. In On At Page Pages Rule Rules Cir. Supp. Tr. Year Years
188
+ East West North South And Or The Of To CA Art. Article Para. Count Counts Exhibit Ex. Doc. Dkt. ECF
189
+ January February March April May June July August September October November December
190
+ Jan. Feb. Mar. Apr. Jun. Jul. Aug. Sep. Sept. Oct. Nov. Dec.
191
+ C Civ Civ. Cr Cr. Crim. Crim CV CR
192
+ """.split(whereSeparator: { $0 == " " || $0 == "\n" }).map { Array($0.utf8) })
193
+
194
+ // ---------------------------------------------------------------- normalize + encode
195
+ let OTHERB: UInt8 = 0xFF
196
+
197
+ /// Map the match bytes to one byte per character (normalize() steps 1-3: NFKC-ish,
198
+ /// quote/dash/section mapping, whitespace collapse + strip, lowercase).
199
+ @inline(__always) func charsOf(_ t: Text, _ s: Int, _ e: Int, into out: inout [UInt8]) {
200
+ out.removeAll(keepingCapacity: true)
201
+ var i = s
202
+ var pendingSpace = false
203
+ while i < e {
204
+ let w = t.ws(i)
205
+ if w > 0 { pendingSpace = !out.isEmpty; i += w; continue }
206
+ var c: UInt8
207
+ let b = t.p[i]
208
+ if b < 0x80 {
209
+ c = isUpper(b) ? b + 32 : (b == 0x60 ? 0x27 : b); i += 1
210
+ } else {
211
+ let b1 = t[i + 1], b2 = t[i + 2]
212
+ if b == 0xE2 && b1 == 0x80 && (b2 == 0x98 || b2 == 0x99) { c = 0x27; i += 3 }
213
+ else if b == 0xE2 && b1 == 0x80 && (b2 == 0x93 || b2 == 0x94) { c = 0x2D; i += 3 }
214
+ else if b == 0xC2 && b1 == 0xA7 { c = 0x73; i += 2 }
215
+ else {
216
+ c = OTHERB
217
+ i += 1
218
+ while i < e && (t.p[i] & 0xC0) == 0x80 { i += 1 }
219
+ }
220
+ }
221
+ if pendingSpace { out.append(0x20); pendingSpace = false }
222
+ out.append(c)
223
+ }
224
+ }
225
+
226
+ @inline(__always) func isWordB(_ c: UInt8) -> Bool { isLower(c) || isDigit(c) || c == 0x5F || c == OTHERB }
227
+
228
+ let EXPL: [[UInt8]] = ["quoting", "citing", "internal", "alteration", "emphasis", "quotation", "affirmed", "aff'd",
229
+ "rev'd", "cert", "vacated", "overruled", "abrogated", "superseded", "modified"].map { Array($0.utf8) }
230
+
231
+ /// _EXPL: \s*\((?:quoting|citing|...|\d+ ) -> cut the string at the leftmost match
232
+ @inline(__always) func cutExplanatory(_ s: inout [UInt8]) {
233
+ let n = s.count
234
+ var i = 0
235
+ while i < n {
236
+ if s[i] == 0x28 {
237
+ var hit = false
238
+ for w in EXPL where i + 1 + w.count <= n && s[(i + 1)..<(i + 1 + w.count)].elementsEqual(w) { hit = true; break }
239
+ if !hit {
240
+ var q = i + 1
241
+ while q < n && isDigit(s[q]) { q += 1 }
242
+ if q > i + 1 && q < n && s[q] == 0x20 { hit = true }
243
+ }
244
+ if hit {
245
+ var cut = i
246
+ while cut > 0 && s[cut - 1] == 0x20 { cut -= 1 }
247
+ s.removeSubrange(cut..<n)
248
+ return
249
+ }
250
+ }
251
+ i += 1
252
+ }
253
+ }
254
+
255
+ func normalizeChars(_ s: inout [UInt8], _ tmp: inout [UInt8]) {
256
+ cutExplanatory(&s)
257
+ // _TRAIL: (?<=[\d)])[\s.,;:]+$ | [\s,;:]+$ (leftmost position where either matches)
258
+ var k = s.count
259
+ while k > 0 && (s[k - 1] == 0x20 || s[k - 1] == 0x2E || s[k - 1] == 0x2C || s[k - 1] == 0x3B || s[k - 1] == 0x3A) { k -= 1 }
260
+ if k < s.count {
261
+ var cut = -1
262
+ for pos in k..<s.count {
263
+ let prev = pos > 0 ? s[pos - 1] : 0
264
+ if isDigit(prev) || prev == 0x29 { cut = pos; break }
265
+ var allNoDot = true
266
+ for q in pos..<s.count where s[q] == 0x2E { allNoDot = false; break }
267
+ if allNoDot { cut = pos; break }
268
+ }
269
+ if cut >= 0 { s.removeSubrange(cut..<s.count) }
270
+ }
271
+ // _JOIN: \.\s+(?=(?!at\b)[a-z]|\d+(?:d|th|st|nd|rd)\b) -> "."
272
+ tmp.removeAll(keepingCapacity: true)
273
+ var i = 0
274
+ let n = s.count
275
+ while i < n {
276
+ let c = s[i]
277
+ tmp.append(c)
278
+ if c == 0x2E && i + 1 < n && s[i + 1] == 0x20 {
279
+ let j = i + 2
280
+ var join = false
281
+ if j < n && isLower(s[j]) {
282
+ let isAt = s[j] == 0x61 && j + 1 < n && s[j + 1] == 0x74 && !(j + 2 < n && isWordB(s[j + 2]))
283
+ join = !isAt
284
+ } else if j < n && isDigit(s[j]) {
285
+ var q = j
286
+ while q < n && isDigit(s[q]) { q += 1 }
287
+ var e = -1
288
+ if q < n && s[q] == 0x64 { e = q + 1 }
289
+ else if q + 1 < n && ((s[q] == 0x74 && s[q + 1] == 0x68) || (s[q] == 0x73 && s[q + 1] == 0x74) ||
290
+ (s[q] == 0x6E && s[q + 1] == 0x64) || (s[q] == 0x72 && s[q + 1] == 0x64)) { e = q + 2 }
291
+ if e >= 0 && !(e < n && isWordB(s[e])) { join = true }
292
+ }
293
+ if join { i += 2; continue }
294
+ }
295
+ i += 1
296
+ }
297
+ swap(&s, &tmp)
298
+ if s.count > MAXCHARS {
299
+ let head = Array(s[0..<24]), tail = Array(s[(s.count - (MAXCHARS - 24))...])
300
+ s = head + tail
301
+ }
302
+ }
303
+
304
+ let CODE: [UInt8] = {
305
+ var t = [UInt8](repeating: 47, count: 256)
306
+ for d in 0..<10 { t[0x30 + d] = UInt8(3 + d) }
307
+ for l in 0..<26 { t[0x61 + l] = UInt8(13 + l) }
308
+ t[0x20] = 39; t[0x2E] = 40; t[0x2C] = 41; t[0x28] = 42; t[0x29] = 43; t[0x27] = 44; t[0x26] = 45; t[0x2D] = 46
309
+ return t
310
+ }()
311
+
312
+ /// Write BOS + codes + EOS + PAD (SEQ Float16 values) for citation bytes [s, e).
313
+ @inline(__always) func encode(_ t: Text, _ s: Int, _ e: Int, _ dst: UnsafeMutablePointer<Float16>,
314
+ _ buf: inout [UInt8], _ tmp: inout [UInt8]) {
315
+ charsOf(t, s, e, into: &buf)
316
+ normalizeChars(&buf, &tmp)
317
+ dst[0] = 1
318
+ var k = 1
319
+ for c in buf where k < SEQ - 1 { dst[k] = Float16(CODE[Int(c)]); k += 1 }
320
+ dst[k] = 2
321
+ k += 1
322
+ while k < SEQ { dst[k] = 0; k += 1 }
323
+ }
bench/bench.swift ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Native Core ML benchmark for the hand-written CiteNet builds.
2
+ // usage: bench <build dir>
3
+ import CoreML
4
+ import Foundation
5
+
6
+ let dir = URL(fileURLWithPath: CommandLine.arguments[1])
7
+ let seq = 64
8
+ let raw = try Data(contentsOf: dir.appendingPathComponent("bench_codes.f16"))
9
+ let nCites = raw.count / (seq * 2)
10
+
11
+ func load(_ b: Int, _ units: MLComputeUnits) throws -> MLModel {
12
+ let cfg = MLModelConfiguration()
13
+ cfg.computeUnits = units
14
+ return try MLModel(contentsOf: dir.appendingPathComponent("CiteNet_b\(b).mlmodelc"), configuration: cfg)
15
+ }
16
+
17
+ func batch(_ b: Int, offset: Int) throws -> MLDictionaryFeatureProvider {
18
+ let arr = try MLMultiArray(shape: [NSNumber(value: b), 1, 1, NSNumber(value: seq)], dataType: .float16)
19
+ raw.withUnsafeBytes { src in
20
+ let dst = arr.dataPointer
21
+ for i in 0..<b {
22
+ let c = (offset + i) % nCites
23
+ memcpy(dst + i * seq * 2, src.baseAddress! + c * seq * 2, seq * 2)
24
+ }
25
+ }
26
+ return try MLDictionaryFeatureProvider(dictionary: ["codes": arr])
27
+ }
28
+
29
+ func now() -> UInt64 { DispatchTime.now().uptimeNanoseconds }
30
+
31
+ func pct(_ v: [Double], _ p: Double) -> Double { v.sorted()[min(v.count - 1, Int(Double(v.count) * p))] }
32
+
33
+ let unitsName: [(String, MLComputeUnits)] = [("cpu", .cpuOnly), ("ane", .cpuAndNeuralEngine), ("gpu", .cpuAndGPU)]
34
+ let only = CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : "all"
35
+ for b in [1, 32, 512, 2048, 8192] where only == "all" {
36
+ for (uname, units) in unitsName {
37
+ if b == 1 && uname == "gpu" { continue } // b1/b32 plans stay on CPU for GPU units
38
+ if b >= 2048 && uname == "cpu" { continue }
39
+ let m = try load(b, units)
40
+ let inputs = try (0..<8).map { try batch(b, offset: $0 * b) }
41
+ for i in 0..<50 { _ = try m.prediction(from: inputs[i % 8]) }
42
+ let iters = b == 1 ? 5000 : (b == 32 ? 3000 : max(40, 300_000 / b))
43
+ var lat = [Double]()
44
+ let t0 = now()
45
+ for i in 0..<iters {
46
+ let s = now()
47
+ _ = try m.prediction(from: inputs[i % 8])
48
+ lat.append(Double(now() - s) / 1e3)
49
+ }
50
+ let total = Double(now() - t0) / 1e9
51
+ print(String(format: "b%-4d %@ p50 %7.1f us p99 %7.1f us %8.0f cites/s", b, uname,
52
+ pct(lat, 0.5), pct(lat, 0.99), Double(iters * b) / total))
53
+ }
54
+ }
55
+
56
+ // ---- concurrent: one thread per compute unit, each with its own model, for a fixed time
57
+ func concurrent(_ lanes: [(String, Int, MLComputeUnits)], seconds: Double) throws {
58
+ let models = try lanes.map { try load($0.1, $0.2) }
59
+ let inputs = try lanes.map { l in try (0..<4).map { try batch(l.1, offset: $0 * l.1) } }
60
+ for (i, m) in models.enumerated() { for j in 0..<20 { _ = try m.prediction(from: inputs[i][j % 4]) } }
61
+ var counts = [Int](repeating: 0, count: lanes.count)
62
+ let lock = NSLock()
63
+ let start = now(), deadline = start + UInt64(seconds * 1e9)
64
+ DispatchQueue.concurrentPerform(iterations: lanes.count) { i in
65
+ var n = 0, j = 0
66
+ while now() < deadline {
67
+ _ = try! models[i].prediction(from: inputs[i][j % 4]); j += 1; n += lanes[i].1
68
+ }
69
+ lock.lock(); counts[i] = n; lock.unlock()
70
+ }
71
+ let total = Double(now() - start) / 1e9
72
+ let parts = zip(lanes, counts).map { String(format: "%@ b%d %.0fk/s", $0.0.0, $0.0.1, Double($0.1) / total / 1e3) }
73
+ print(String(format: "concurrent %@ -> %.0f cites/s", parts.joined(separator: " + "), Double(counts.reduce(0, +)) / total))
74
+ }
75
+ try concurrent([("ane", 512, .cpuAndNeuralEngine)], seconds: 3)
76
+ try concurrent([("gpu", 8192, .cpuAndGPU)], seconds: 3)
77
+ try concurrent([("ane", 512, .cpuAndNeuralEngine), ("gpu", 8192, .cpuAndGPU)], seconds: 3)
78
+ try concurrent([("ane", 2048, .cpuAndNeuralEngine), ("gpu", 8192, .cpuAndGPU)], seconds: 3)
79
+ try concurrent([("ane", 512, .cpuAndNeuralEngine), ("ane", 512, .cpuAndNeuralEngine), ("gpu", 8192, .cpuAndGPU), ("gpu", 8192, .cpuAndGPU)], seconds: 3)
bench/firehose.swift ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Firehose: push N citations through CiteNet on the ANE, the GPU, or both at once.
2
+ // usage: firehose <build dir> <ane|gpu|ane+gpu> <n> [threads per unit, default 2]
3
+ // Prints one JSON line. Model loading is excluded from the timing.
4
+ import CoreML
5
+ import Foundation
6
+
7
+ let dir = URL(fileURLWithPath: CommandLine.arguments[1])
8
+ let lanesArg = CommandLine.arguments[2]
9
+ let target = Int(CommandLine.arguments[3]) ?? 1_000_000
10
+ let seq = 64
11
+ let raw = try Data(contentsOf: dir.appendingPathComponent("bench_codes.f16"))
12
+ let nCites = raw.count / (seq * 2)
13
+
14
+ struct Lane { let name: String; let batch: Int; let units: MLComputeUnits }
15
+ let all: [String: Lane] = [
16
+ "ane": Lane(name: "ane", batch: 512, units: .cpuAndNeuralEngine),
17
+ "gpu": Lane(name: "gpu", batch: 8192, units: .cpuAndGPU),
18
+ ]
19
+ // each unit gets `threads` lanes (own model instance each) so one lane's CPU-side
20
+ // work overlaps the other's time on the accelerator
21
+ let threads = CommandLine.arguments.count > 4 ? Int(CommandLine.arguments[4])! : 2
22
+ let lanes = lanesArg.split(separator: "+").flatMap { name in (0..<threads).map { _ in all[String(name)]! } }
23
+
24
+ func input(_ b: Int, offset: Int) throws -> MLDictionaryFeatureProvider {
25
+ let arr = try MLMultiArray(shape: [NSNumber(value: b), 1, 1, NSNumber(value: seq)], dataType: .float16)
26
+ raw.withUnsafeBytes { src in
27
+ for i in 0..<b {
28
+ memcpy(arr.dataPointer + i * seq * 2, src.baseAddress! + ((offset + i) % nCites) * seq * 2, seq * 2)
29
+ }
30
+ }
31
+ return try MLDictionaryFeatureProvider(dictionary: ["codes": arr])
32
+ }
33
+
34
+ func flagged(_ out: MLFeatureProvider, _ b: Int) -> Int {
35
+ // ANE outputs come back with padded rows, so walk the batch axis by its stride
36
+ let p = out.featureValue(for: "p_fake")!.multiArrayValue!
37
+ let stride = p.strides[0].intValue
38
+ var n = 0
39
+ if p.dataType == .float16 {
40
+ let ptr = p.dataPointer.bindMemory(to: Float16.self, capacity: b * stride)
41
+ for i in 0..<b where ptr[i * stride] >= 0.5 { n += 1 }
42
+ } else {
43
+ let ptr = p.dataPointer.bindMemory(to: Float.self, capacity: b * stride)
44
+ for i in 0..<b where ptr[i * stride] >= 0.5 { n += 1 }
45
+ }
46
+ return n
47
+ }
48
+
49
+ let models = try lanes.map { l -> MLModel in
50
+ let cfg = MLModelConfiguration(); cfg.computeUnits = l.units
51
+ return try MLModel(contentsOf: dir.appendingPathComponent("CiteNet_lean_b\(l.batch).mlmodelc"), configuration: cfg)
52
+ }
53
+ let inputs = try lanes.map { l in try (0..<4).map { try input(l.batch, offset: $0 * 977) } }
54
+ for (i, m) in models.enumerated() { for j in 0..<8 { _ = try m.prediction(from: inputs[i][j % 4]) } }
55
+
56
+ let lock = NSLock()
57
+ var done = 0
58
+ var perLane = [Int](repeating: 0, count: lanes.count)
59
+ var flags = 0
60
+ let t0 = DispatchTime.now().uptimeNanoseconds
61
+ DispatchQueue.concurrentPerform(iterations: lanes.count) { i in
62
+ var j = 0
63
+ while true {
64
+ lock.lock()
65
+ if done >= target { lock.unlock(); break }
66
+ done += lanes[i].batch
67
+ lock.unlock()
68
+ let out = try! models[i].prediction(from: inputs[i][j % 4]); j += 1
69
+ let f = flagged(out, lanes[i].batch)
70
+ lock.lock(); perLane[i] += lanes[i].batch; flags += f; lock.unlock()
71
+ }
72
+ }
73
+ let secs = Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e9
74
+ let total = perLane.reduce(0, +)
75
+ var byUnit: [String: Int] = [:]
76
+ for (l, n) in zip(lanes, perLane) { byUnit[l.name, default: 0] += n }
77
+ let laneJSON = byUnit.keys.sorted().map { "\"\($0)\": {\"batch\": \(all[$0]!.batch), \"threads\": \(threads), \"cites\": \(byUnit[$0]!)}" }.joined(separator: ", ")
78
+ print("{\"n\": \(total), \"seconds\": \(String(format: "%.4f", secs)), \"per_sec\": \(Int(Double(total) / secs)), \"flagged\": \(flags), \"lanes\": {\(laneJSON)}}")
bench/scanparity.swift ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // scanparity <text file> <out prefix>: run the native scanner, write codes (.f16) and cites (.txt, one per line)
2
+ import Foundation
3
+
4
+ @main struct ScanParity {
5
+ static func main() throws {
6
+ let a = CommandLine.arguments
7
+ let data = try Data(contentsOf: URL(fileURLWithPath: a[1]), options: .alwaysMapped)
8
+ var cites = [String](), codes = [Float16]()
9
+ var buf = [UInt8](), tmp = [UInt8]()
10
+ let row = UnsafeMutablePointer<Float16>.allocate(capacity: SEQ)
11
+ let t0 = Date()
12
+ var n = 0
13
+ data.withUnsafeBytes { raw in
14
+ let t = Text(p: raw.bindMemory(to: UInt8.self).baseAddress!, n: raw.count)
15
+ t.scan(0, raw.count) { s, e in
16
+ encode(t, s, e, row, &buf, &tmp)
17
+ n += 1
18
+ if a.count > 2 {
19
+ codes.append(contentsOf: UnsafeBufferPointer(start: row, count: SEQ))
20
+ cites.append(String(decoding: UnsafeBufferPointer(start: t.p + s, count: e - s), as: UTF8.self))
21
+ }
22
+ }
23
+ }
24
+ let dt = Date().timeIntervalSince(t0)
25
+ print(String(format: "%d citations, %.1f MB in %.3f s -> %.0f MB/s single thread", n, Double(data.count) / 1e6, dt, Double(data.count) / 1e6 / dt))
26
+ if a.count > 2 {
27
+ try codes.withUnsafeBufferPointer { try Data(buffer: $0).write(to: URL(fileURLWithPath: a[2] + ".f16")) }
28
+ try (cites.map { $0.replacingOccurrences(of: "\r", with: "\\r").replacingOccurrences(of: "\n", with: "\\n") }.joined(separator: "\n")).write(toFile: a[2] + ".txt", atomically: true, encoding: .utf8)
29
+ }
30
+ }
31
+ }
bench/stream/main.swift ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // stream: raw text -> native scanner threads -> CiteNet on the ANE and GPU, live.
2
+ //
3
+ // usage: stream_bin <build dir> <corpus.txt> [--loop] [--scan-threads N] [--scan-only]
4
+ //
5
+ // Scanner threads take ~1 MB blocks of the memory-mapped corpus, find citations
6
+ // (CiteScan.swift, byte-identical to citenet/text.py) and pack their codes into
7
+ // 512-row batches. Two ANE lanes (lean b512) and two GPU lanes (lean b8192, 16 batches at a
8
+ // time) run the model. Every 100 ms a JSON line with counters, a few sampled
9
+ // citations and newly flagged ones goes to stdout.
10
+ import CoreML
11
+ import Foundation
12
+
13
+ let args = CommandLine.arguments
14
+ let dir = URL(fileURLWithPath: args[1])
15
+ let corpusURL = URL(fileURLWithPath: args[2])
16
+ let loop = args.contains("--loop")
17
+ let scanOnly = args.contains("--scan-only")
18
+ let scanThreads = args.firstIndex(of: "--scan-threads").map { Int(args[$0 + 1])! } ?? 4
19
+ let B = 512
20
+
21
+ let corpus = try Data(contentsOf: corpusURL, options: .alwaysMapped)
22
+ let base: UnsafePointer<UInt8> = corpus.withUnsafeBytes { $0.bindMemory(to: UInt8.self).baseAddress! }
23
+ let N = corpus.count
24
+ let T = Text(p: base, n: N)
25
+
26
+ // ~1 MB blocks split on blank lines (the corpus separates opinions with "\n\n")
27
+ var blocks = [(Int, Int)]()
28
+ do {
29
+ var s = 0
30
+ while s < N {
31
+ var e = min(N, s + 1 << 20)
32
+ while e < N && !(base[e - 1] == 0x0A && base[e] == 0x0A) { e += 1 }
33
+ e = min(N, e + 1)
34
+ blocks.append((s, e)); s = e
35
+ }
36
+ }
37
+
38
+ // ---------------------------------------------------------------- batches + queue
39
+ final class Batch {
40
+ let codes = UnsafeMutablePointer<Float16>.allocate(capacity: B * SEQ)
41
+ var start = [Int](repeating: 0, count: B)
42
+ var len = [Int32](repeating: 0, count: B)
43
+ var count = 0
44
+ }
45
+
46
+ final class Queue {
47
+ let cv = NSCondition()
48
+ var ready = [Batch]()
49
+ var free = [Batch]()
50
+ var producersLeft = scanThreads
51
+ init(_ n: Int) { free = (0..<n).map { _ in Batch() } }
52
+ func take() -> Batch {
53
+ cv.lock(); defer { cv.unlock() }
54
+ while free.isEmpty { cv.wait() }
55
+ return free.removeLast()
56
+ }
57
+ func push(_ b: Batch) { cv.lock(); ready.append(b); cv.broadcast(); cv.unlock() }
58
+ func recycle(_ bs: [Batch]) { cv.lock(); for b in bs { b.count = 0 }; free.append(contentsOf: bs); cv.broadcast(); cv.unlock() }
59
+ /// Up to k ready batches; waits for k unless the producers are done. Empty = finished.
60
+ func pop(_ k: Int) -> [Batch] {
61
+ cv.lock(); defer { cv.unlock() }
62
+ while ready.count < k && producersLeft > 0 { cv.wait() }
63
+ let n = min(k, ready.count)
64
+ let out = Array(ready.prefix(n)); ready.removeFirst(n)
65
+ return out
66
+ }
67
+ func producerDone() { cv.lock(); producersLeft -= 1; cv.broadcast(); cv.unlock() }
68
+ }
69
+ let Q = Queue(scanOnly ? 64 : 512)
70
+
71
+ // ---------------------------------------------------------------- shared stats
72
+ final class Stats {
73
+ let lock = NSLock()
74
+ var bytes = 0, cites = 0, checked = 0, flagged = 0, ane = 0, gpu = 0, passes = 0
75
+ var ticker = [(Int, Int32, Bool)]() // sampled (start, len, flagged)
76
+ var caught = [(Int, Int32, Int, Float)]() // (start, len, top evidence, p)
77
+ }
78
+ let S = Stats()
79
+ var nextBlock = 0
80
+ let blockLock = NSLock()
81
+
82
+ // ---------------------------------------------------------------- scanner threads
83
+ func producer() {
84
+ var buf = [UInt8](), tmp = [UInt8]()
85
+ var cur = Q.take()
86
+ while true {
87
+ blockLock.lock()
88
+ let idx = nextBlock; nextBlock += 1
89
+ blockLock.unlock()
90
+ if !loop && idx >= blocks.count { break }
91
+ let (lo, hi) = blocks[idx % blocks.count]
92
+ var found = 0
93
+ T.scan(lo, hi) { s, e in
94
+ encode(T, s, e, cur.codes + cur.count * SEQ, &buf, &tmp)
95
+ cur.start[cur.count] = s; cur.len[cur.count] = Int32(e - s)
96
+ cur.count += 1; found += 1
97
+ if cur.count == B {
98
+ if scanOnly { cur.count = 0 } else { Q.push(cur); cur = Q.take() }
99
+ }
100
+ }
101
+ S.lock.lock(); S.bytes += hi - lo; S.cites += found; S.passes = max(S.passes, idx / blocks.count); S.lock.unlock()
102
+ }
103
+ if cur.count > 0 && !scanOnly { Q.push(cur) } else { Q.recycle([cur]) }
104
+ Q.producerDone()
105
+ }
106
+
107
+ // ---------------------------------------------------------------- model lanes
108
+ func lane(_ name: String, _ modelBatch: Int, _ units: MLComputeUnits) throws {
109
+ let cfg = MLModelConfiguration(); cfg.computeUnits = units
110
+ let m = try MLModel(contentsOf: dir.appendingPathComponent("CiteNet_lean_b\(modelBatch).mlmodelc"), configuration: cfg)
111
+ let arr = try MLMultiArray(shape: [NSNumber(value: modelBatch), 1, 1, NSNumber(value: SEQ)], dataType: .float16)
112
+ let input = try MLDictionaryFeatureProvider(dictionary: ["codes": arr])
113
+ let per = modelBatch / B
114
+ var sampleEvery = 0
115
+ while true {
116
+ let bs = Q.pop(per)
117
+ if bs.isEmpty { return }
118
+ let dst = arr.dataPointer.bindMemory(to: Float16.self, capacity: modelBatch * SEQ)
119
+ var rows = 0
120
+ for b in bs { memcpy(dst + rows * SEQ, b.codes, b.count * SEQ * 2); rows += b.count }
121
+ if rows < modelBatch { memset(dst + rows * SEQ, 0, (modelBatch - rows) * SEQ * 2) }
122
+ let out = try m.prediction(from: input)
123
+ let p = out.featureValue(for: "p_fake")!.multiArrayValue!
124
+ let ev = out.featureValue(for: "evidence")!.multiArrayValue!
125
+ let ps = p.strides[0].intValue, es = ev.strides[0].intValue, ec = ev.strides[1].intValue
126
+ let pp = p.dataPointer.bindMemory(to: Float16.self, capacity: modelBatch * ps)
127
+ let ep = ev.dataPointer.bindMemory(to: Float16.self, capacity: modelBatch * es)
128
+ var nflag = 0
129
+ var newCaught = [(Int, Int32, Int, Float)]()
130
+ var r = 0
131
+ for b in bs {
132
+ for i in 0..<b.count {
133
+ if pp[r * ps] >= 0.5 {
134
+ nflag += 1
135
+ if newCaught.count < 8 {
136
+ var best = 0
137
+ for k in 1..<7 where ep[r * es + k * ec] > ep[r * es + best * ec] { best = k }
138
+ newCaught.append((b.start[i], b.len[i], best, Float(pp[r * ps])))
139
+ }
140
+ }
141
+ r += 1
142
+ }
143
+ }
144
+ sampleEvery += 1
145
+ S.lock.lock()
146
+ S.checked += rows; S.flagged += nflag
147
+ if name == "ane" { S.ane += rows } else { S.gpu += rows }
148
+ if S.caught.count < 64 { S.caught.append(contentsOf: newCaught.prefix(64 - S.caught.count)) }
149
+ if S.ticker.count < 32, let b = bs.first, b.count > 0 {
150
+ let i = (sampleEvery * 131) % b.count
151
+ S.ticker.append((b.start[i], b.len[i], pp[i * ps] >= 0.5))
152
+ }
153
+ S.lock.unlock()
154
+ Q.recycle(bs)
155
+ }
156
+ }
157
+
158
+ // ---------------------------------------------------------------- JSON out
159
+ func jstr(_ s: Int, _ e: Int) -> String {
160
+ let lo = max(0, s), hi = min(N, e)
161
+ var str = String(decoding: UnsafeBufferPointer(start: base + lo, count: max(0, hi - lo)), as: UTF8.self)
162
+ str = str.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
163
+ str = str.unicodeScalars.map { $0.value < 0x20 ? " " : String($0) }.joined()
164
+ return "\"" + str + "\""
165
+ }
166
+ func ctxStart(_ s: Int) -> Int { var i = max(0, s - 90); while i < s && (base[i] & 0xC0) == 0x80 { i += 1 }; return i }
167
+ func ctxEnd(_ e: Int) -> Int { var i = min(N, e + 90); while i < N && (base[i] & 0xC0) == 0x80 { i += 1 }; return i }
168
+
169
+ let t0 = DispatchTime.now().uptimeNanoseconds
170
+ func elapsed() -> Double { Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e9 }
171
+
172
+ let group = DispatchGroup()
173
+ for _ in 0..<scanThreads { group.enter(); Thread { producer(); group.leave() }.start() }
174
+ var laneErr: Error?
175
+ if !scanOnly {
176
+ for (name, mb, units) in [("ane", 512, MLComputeUnits.cpuAndNeuralEngine), ("ane", 512, .cpuAndNeuralEngine),
177
+ ("gpu", 8192, .cpuAndGPU), ("gpu", 8192, .cpuAndGPU)] {
178
+ group.enter()
179
+ Thread { do { try lane(name, mb, units) } catch { laneErr = error }; group.leave() }.start()
180
+ }
181
+ }
182
+ var finished = false
183
+ DispatchQueue.global().async { group.wait(); finished = true }
184
+
185
+ print("{\"start\": true, \"corpus_bytes\": \(N), \"blocks\": \(blocks.count), \"scan_threads\": \(scanThreads), \"scan_only\": \(scanOnly)}")
186
+ fflush(stdout)
187
+ var last = (0.0, 0, 0, 0)
188
+ while true {
189
+ usleep(100_000)
190
+ let t = elapsed()
191
+ S.lock.lock()
192
+ let (bytes, cites, checked, flagged, ane, gpu, passes) = (S.bytes, S.cites, S.checked, S.flagged, S.ane, S.gpu, S.passes)
193
+ let ticker = S.ticker; S.ticker.removeAll()
194
+ let caught = S.caught; S.caught.removeAll()
195
+ S.lock.unlock()
196
+ let dt = t - last.0
197
+ let mbps = Double(bytes - last.1) / dt / 1e6, cps = Double(checked - last.2) / dt
198
+ let sps = Double(cites - last.3) / dt
199
+ last = (t, bytes, checked, cites)
200
+ let tick = ticker.suffix(8).map { (s, l, f) in
201
+ "{\"before\": \(jstr(ctxStart(s), s)), \"cite\": \(jstr(s, s + Int(l))), \"after\": \(jstr(s + Int(l), ctxEnd(s + Int(l)))), \"flag\": \(f)}"
202
+ }.joined(separator: ", ")
203
+ let cg = caught.prefix(8).map { (s, l, ev, p) in
204
+ "{\"cite\": \(jstr(s, s + Int(l))), \"before\": \(jstr(ctxStart(s), s)), \"ev\": \(ev), \"p\": \(String(format: "%.3f", p))}"
205
+ }.joined(separator: ", ")
206
+ let done = finished
207
+ print("{\"t\": \(String(format: "%.2f", t)), \"bytes\": \(bytes), \"cites\": \(cites), \"checked\": \(checked), \"flagged\": \(flagged), " +
208
+ "\"ane\": \(ane), \"gpu\": \(gpu), \"pass\": \(passes), \"mbps\": \(String(format: "%.1f", mbps)), \"cps\": \(Int(cps)), \"sps\": \(Int(sps)), " +
209
+ "\"ticker\": [\(tick)], \"caught\": [\(cg)], \"done\": \(done)}")
210
+ fflush(stdout)
211
+ if done { break }
212
+ }
213
+ if let e = laneErr { FileHandle.standardError.write("lane error: \(e)\n".data(using: .utf8)!); exit(1) }
citenet/__init__.py ADDED
File without changes
citenet/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (142 Bytes). View file
 
citenet/__pycache__/spec.cpython-312.pyc ADDED
Binary file (18.1 kB). View file
 
citenet/__pycache__/text.cpython-312.pyc ADDED
Binary file (5.26 kB). View file
 
citenet/net.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CiteNet: a char-level conv + ReLU MLP whose weights are all written by hand.
2
+
3
+ build() compiles spec.py into weight tensors. No gradient step ever touches
4
+ them. Layer by layer:
5
+
6
+ codes [B,1,1,64] char codes from text.encode()
7
+ onehot relu(1 - |code - k|) for k in 0..47 (no weights)
8
+ conv_rep K=15 one filter per reporter abbreviation; it fires (=1) only when
9
+ every character of "<digit> <abbr><space>" lines up. Anchored right
10
+ after the volume, so it only scans the first ANCHOR_W columns
11
+ conv_float K=15 series ordinals, LEXIS, law reviews: anywhere in the string
12
+ conv_court K=8 circuit / district / bankruptcy / 48 state parentheticals
13
+ conv_num K=6 gated digit decoders: volume (anchored at BOS) and the year
14
+ before ')'; the value filter has weight k*10^i on digit k
15
+ max over positions -> one binary feature per filter, plus the numbers
16
+ fc1 concepts reporter classes, court classes, a shared ReLU "volume
17
+ thermometer", phantom-series detectors
18
+ fc2 rules conjunctions such as relu(SCOTUS + circuit - 1) and the
19
+ curve residuals relu(|year - curve(volume)| - tol)
20
+ fc3 evidence sums each rule group; fc3b saturates it at its cap
21
+ fc4 logit bias + weighted evidence -> sigmoid
22
+ """
23
+ from collections import OrderedDict
24
+
25
+ import numpy as np
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.nn.functional as F
29
+
30
+ from . import spec
31
+
32
+ K_REP, K_COURT, K_NUM = 15, 8, 6
33
+ ANCHOR_W = 5 + K_REP - 1 # BOS + up to 4 volume digits: template starts at columns 0..4
34
+ GATE = 400.0 # big-M for gating curve residuals (residuals stay < 400 years)
35
+ VGATE = 1024.0 # big-M for gating volume > vmax (3-digit volumes stay < 1000)
36
+ AND_N = 8.0 # at most a few court detectors ever fire on one citation
37
+ YEAR0 = 1700.0 # years are carried as (year - 1700) so they stay small in fp16
38
+
39
+
40
+ # ------------------------------------------------------------------ helpers
41
+ def template_filter(t, k):
42
+ sets = spec.parse_template(t)
43
+ assert len(sets) <= k, (t, len(sets), k)
44
+ w = np.zeros((spec.NCHAR, k), np.float32)
45
+ for o, allowed in enumerate(sets):
46
+ for c in allowed:
47
+ w[c, o] = 1.0
48
+ return w, -(len(sets) - 1.0)
49
+
50
+
51
+ def rep_template(abbr):
52
+ if "_" in abbr: # the entry carries its own right context ("us_#", "us_at_")
53
+ return "#_" + abbr
54
+ return "#_" + abbr + "~"
55
+
56
+
57
+ class Layer:
58
+ """Named-neuron builder for one dense layer: in_names -> out_names."""
59
+
60
+ def __init__(self, in_names):
61
+ self.idx = {n: i for i, n in enumerate(in_names)}
62
+ self.rows = OrderedDict()
63
+
64
+ def add(self, name, terms, bias=0.0, sat=False):
65
+ row = np.zeros(len(self.idx), np.float32)
66
+ for n, w in terms.items():
67
+ row[self.idx[n]] += w
68
+ self.rows[name] = (row, bias)
69
+ if sat: # companion neuron relu(x - 1): the next layer reads min(x, 1) = x - x^hi
70
+ self.rows[name + "^hi"] = (row.copy(), bias - 1.0)
71
+
72
+ def tensors(self):
73
+ names = list(self.rows)
74
+ W = np.stack([self.rows[n][0] for n in names])
75
+ b = np.array([self.rows[n][1] for n in names], np.float32)
76
+ return names, W, b
77
+
78
+
79
+ def curve_at(pts, v):
80
+ xs, ys = zip(*pts)
81
+ return float(np.interp(v, xs, ys, left=None, right=None)) if xs[0] <= v <= xs[-1] else (
82
+ ys[0] + (v - xs[0]) * (ys[1] - ys[0]) / (xs[1] - xs[0]) if v < xs[0]
83
+ else ys[-1] + (v - xs[-1]) * (ys[-1] - ys[-2]) / (xs[-1] - xs[-2]))
84
+
85
+
86
+ def knot_readout(pts, vmax):
87
+ """Express curve(v) - YEAR0 as a + sum_j c_j * relu(v - knot_j), interpolating
88
+ the curve on the shared knot grid. The curve is held flat outside
89
+ [first volume, vmax]: volumes past vmax are caught by the over: neurons, and
90
+ the flat ends keep every residual below GATE."""
91
+ ks = spec.KNOTS
92
+ lo = pts[0][0]
93
+ hi = min(k for k in ks if k >= vmax) # hold flat from the first knot past vmax
94
+ ys = [curve_at(pts, min(max(k, lo), hi)) - YEAR0 for k in ks]
95
+ assert max(ys) < GATE - 10, (pts, max(ys))
96
+ slopes = [(ys[j + 1] - ys[j]) / (ks[j + 1] - ks[j]) for j in range(len(ks) - 1)]
97
+ coef = {}
98
+ prev = 0.0
99
+ for j, s in enumerate(slopes):
100
+ coef["vol>%d" % ks[j]] = s - prev
101
+ prev = s
102
+ return ys[0], coef
103
+
104
+
105
+ # ------------------------------------------------------------------ build
106
+ def build():
107
+ P = OrderedDict() # weights
108
+ meta = {}
109
+
110
+ # ---- conv_rep (anchored) + conv_float
111
+ # A reporter template "#_abbr~" starts on the last digit of the volume, which sits at
112
+ # positions 1-4 right after BOS, so conv_rep only looks at the first ANCHOR_W columns
113
+ # (5 positions). Templates that can sit anywhere (series ordinals, LEXIS, law reviews)
114
+ # go in conv_float, which scans the whole sequence.
115
+ templates = [("rep:" + a, rep_template(a)) for a, *_ in spec.REPORTERS]
116
+ templates += [("series:" + n, t) for n, t in spec.PHANTOM_SERIES.items()]
117
+ templates += [("other:" + n, t) for n, t in spec.OTHER_SOURCES.items()]
118
+ anchored = [(n, t) for n, t in templates if t.startswith("#_")]
119
+ floating = [(n, t) for n, t in templates if not t.startswith("#_")]
120
+ for conv, group in (("conv_rep", anchored), ("conv_float", floating)):
121
+ ws, bs = zip(*(template_filter(t, K_REP) for _, t in group))
122
+ P[conv + ".weight"] = np.stack(ws)[:, :, None, :]
123
+ P[conv + ".bias"] = np.array(bs, np.float32)
124
+ rep_names = [n for n, _ in anchored] + [n for n, _ in floating]
125
+ meta["anchored"], meta["floating"] = [n for n, _ in anchored], [n for n, _ in floating]
126
+
127
+ # ---- conv_court
128
+ court_names, cw, cb = [], [], []
129
+ for name, t in spec.COURTS.items():
130
+ w, b = template_filter(t, K_COURT)
131
+ court_names.append("court:" + name); cw.append(w); cb.append(b)
132
+ P["conv_court.weight"] = np.stack(cw)[:, :, None, :]
133
+ P["conv_court.bias"] = np.array(cb, np.float32)
134
+
135
+ # ---- conv_num: gates (template) and values (digit place values)
136
+ # the year must follow a space, '(', '.' or ',' (so "(03-1202)" docket numbers are not years)
137
+ # and look like 17xx-20xx (so a page number closing a "(quoting ...)" parenthetical is not one)
138
+ gate_t = {"vol1": "<#!", "vol2": "<##!", "vol3": "<###!", "vol4": "<####!",
139
+ "year": "{ (.,}1{789}##)", "year20": "{ (.,}20##)"}
140
+ num_names = list(gate_t) + ["vol4y"]
141
+ gw, gb, vw, vb = [], [], [], []
142
+ for name in num_names:
143
+ t = gate_t["vol4" if name == "vol4y" else name]
144
+ w, b = template_filter(t, K_NUM)
145
+ gw.append(w); gb.append(b)
146
+ v = np.zeros((spec.NCHAR, K_NUM), np.float32)
147
+ digit_offsets = [o for o, st in enumerate(spec.parse_template(t)) if st and st <= spec.DIGITS]
148
+ n = len(digit_offsets)
149
+ for i, o in enumerate(digit_offsets):
150
+ for d in range(10):
151
+ v[spec.DIGIT0 + d, o] = d * 10 ** (n - 1 - i)
152
+ vw.append(v)
153
+ vb.append(-YEAR0 if name in ("year", "year20", "vol4y") else 0.0)
154
+ P["conv_gate.weight"] = np.stack(gw)[:, :, None, :]
155
+ P["conv_gate.bias"] = np.array(gb, np.float32)
156
+ P["conv_val.weight"] = np.stack(vw)[:, :, None, :]
157
+ P["conv_val.bias"] = np.array(vb, np.float32)
158
+
159
+ # numbers after pooling: vol1..vol4, year, vol4y (value*gate) + gates
160
+ feat = rep_names + court_names + ["num:" + n for n in num_names] + \
161
+ ["gate:" + n for n in num_names]
162
+ meta["features"] = feat
163
+
164
+ # ---- fc1: concepts
165
+ L1 = Layer(feat)
166
+
167
+ def reps(abbrs):
168
+ """Sum over reporter templates, minus the templates each one excludes."""
169
+ terms = {}
170
+ for a in abbrs:
171
+ terms["rep:" + a] = terms.get("rep:" + a, 0) + 1
172
+ for x in spec.EXCLUDES.get(a, []):
173
+ terms["rep:" + x] = terms.get("rep:" + x, 0) - 1
174
+ return terms
175
+
176
+ classes = sorted({r[1] for r in spec.REPORTERS})
177
+ for c in classes:
178
+ L1.add("class:" + c, reps(a for a, cls, _, _ in spec.REPORTERS if cls == c), sat=True)
179
+ regions = sorted(spec.REGION_STATES)
180
+ for r in regions:
181
+ L1.add("region:" + r, reps(a for a, cls, h, _ in spec.REPORTERS if cls == "REGION" and h == r), sat=True)
182
+ homes = sorted({h for _, cls, h, _ in spec.REPORTERS if cls == "STATE"})
183
+ for h in homes:
184
+ L1.add("home:" + h, reps(a for a, cls, hh, _ in spec.REPORTERS if cls == "STATE" and hh == h), sat=True)
185
+ curves = list(spec.CURVES)
186
+ for k in curves:
187
+ L1.add("is:" + k, reps(a for a, _, _, cv in spec.REPORTERS if cv == k), sat=True)
188
+ L1.add("is:wl", {"rep:wl": 1}, sat=True)
189
+ L1.add("is:lexis", {"other:lexis": 1}, sat=True)
190
+ # phantom series: an ordinal glued to an abbreviation that never had that series
191
+ alr = {"other:a.l.r.": -1} # A.L.R. 4th..7th are real annotation series
192
+ def ords(k):
193
+ return {"series:" + n: 1 for n in spec.PHANTOM_SERIES if n.rstrip("sc") == k}
194
+ L1.add("phantom:4th", dict(ords("ord4"), **alr, **{"rep:" + a: -1 for a in spec.VALID_4TH}))
195
+ L1.add("phantom:5th", dict(ords("ord5"), **alr, **{"rep:" + a: -1 for a in spec.VALID_5TH}))
196
+ L1.add("phantom:6th+", dict(ords("ord6+"), **alr))
197
+ known = reps(a for a, *_ in spec.REPORTERS)
198
+ known.update({"other:" + n: 1 for n in spec.OTHER_SOURCES})
199
+ for s in spec.PHANTOM_SERIES:
200
+ known["series:" + s] = 1
201
+ L1.add("known_source", known)
202
+ # an explanatory parenthetical ("(quoting X, 550 U.S. at 556 ...)") carries other reporters:
203
+ # volume checks are switched off for these strings (series checks still apply)
204
+ L1.add("expl", {"court:" + n: 1 for n in spec.COURTS if n.startswith("expl_")})
205
+ # courts
206
+ # Louisiana's state appeals courts are named "La. App. 3d Cir.": not a federal circuit
207
+ L1.add("court:circuit", {"court:circuit": 1, "court:circuit_dc": 1, "court:circuit_fed": 1, "court:state_la": -AND_N})
208
+ L1.add("court:district", {"court:district_x": 1, "court:district_d": 1, "court:state_d.c": -1,
209
+ "court:dc_app": -1, "court:dc_mun": -1, "court:circuit_dc": -1})
210
+ L1.add("court:bankruptcy", {"court:bankruptcy": 1, "court:bap": 1})
211
+ for s in spec.STATES:
212
+ L1.add("state:" + s, {"court:state_" + s: 1, **({"court:dc_app": 1, "court:dc_mun": 1} if s == "d.c" else {})})
213
+ L1.add("court:year_only", {"court:year_only": 1})
214
+ # numbers: volume, year (minus 1700), presence flags
215
+ L1.add("volume", {"num:vol1": 1, "num:vol2": 1, "num:vol3": 1, "num:vol4": 1})
216
+ L1.add("has_volume", {"gate:vol1": 1, "gate:vol2": 1, "gate:vol3": 1, "gate:vol4": 1})
217
+ # year and vol4y are relu'd here and clamped to [0, 400] in fc2 as x - x_hi, so a strange
218
+ # 4-digit number ("3128 EDA 2007") can never push a gated rule past its big-M
219
+ yr = {"num:year": 1, "num:year20": 1}
220
+ expl = {"court:" + n: -AND_N for n in spec.COURTS if n.startswith("expl_")}
221
+ L1.add("year", yr)
222
+ L1.add("year_hi", yr, bias=-400.0)
223
+ L1.add("has_year", dict({"gate:year": 1, "gate:year20": 1}, **expl), sat=True) # not inside "(quoting ...)"
224
+ L1.add("vol4y", {"num:vol4y": 1})
225
+ L1.add("vol4y_hi", {"num:vol4y": 1}, bias=-400.0)
226
+ L1.add("future", yr, bias=-(spec.LAST_YEAR - YEAR0 + 0.5))
227
+ # shared volume thermometer relu(v - knot), over 1-3 digit volumes only
228
+ # (4-digit volumes are years in WL/Lexis cites, or past every vmax)
229
+ L1.add("volume3", {"num:vol1": 1, "num:vol2": 1, "num:vol3": 1})
230
+ L1.add("has_vol4", {"gate:vol4": 1})
231
+ vol_terms = {"num:vol1": 1, "num:vol2": 1, "num:vol3": 1}
232
+ for k in spec.KNOTS[:-1]:
233
+ L1.add("vol>%d" % k, vol_terms, bias=-float(k))
234
+ names1, W1, b1 = L1.tensors()
235
+ P["fc1.weight"], P["fc1.bias"] = W1, b1
236
+ meta["concepts"] = names1
237
+
238
+ # ---- fc2: rules
239
+ L2 = Layer(names1)
240
+
241
+ def sat(name, w):
242
+ return {name: w, name + "^hi": -w}
243
+
244
+ def merge(*ds):
245
+ out = {}
246
+ for d in ds:
247
+ for n, w in d.items():
248
+ out[n] = out.get(n, 0) + w
249
+ return out
250
+ fed_courts = {"court:circuit": 1, "court:district": 1, "court:bankruptcy": 1}
251
+ any_state = {"state:" + s: 1 for s in spec.STATES}
252
+
253
+ def conj(name, cls, bad, group):
254
+ # AND(class, any bad court): the class is weighted by AND_N so that several
255
+ # court detectors firing at once can never fire the rule on their own
256
+ L2.add(name, merge(sat(cls, AND_N), bad), bias=-AND_N)
257
+ meta.setdefault("groups", {}).setdefault(group, []).append(name)
258
+
259
+ conj("mm:SCOTUS", "class:SCOTUS", dict(fed_courts, **any_state), "court_mismatch")
260
+ conj("mm:FEDAPP", "class:FEDAPP", dict({"court:district": 1, "court:bankruptcy": 1}, **any_state),
261
+ "court_mismatch")
262
+ # pre-1970s style wrote district courts as "(D.C. Minn. 1960)" / "(D.C. 1968)"
263
+ no_dc = {k: v for k, v in any_state.items() if k != "state:d.c"}
264
+ conj("mm:FEDAPP_OLD", "class:FEDAPP_OLD", no_dc, "court_mismatch")
265
+ conj("mm:FEDDIST", "class:FEDDIST", {"court:circuit": 1}, "court_mismatch") # "(Mass. 1953)" = D. Mass. in older opinions
266
+ conj("mm:BANKR", "class:BANKR", no_dc, "court_mismatch")
267
+ conj("mm:REGION_fed", "class:REGION", fed_courts, "court_mismatch")
268
+ conj("mm:STATE_fed", "class:STATE", fed_courts, "court_mismatch")
269
+ for r in regions:
270
+ bad = {"state:" + s: 1 for s in spec.STATES if s not in spec.REGION_STATES[r]}
271
+ conj("mm:region_" + r, "region:" + r, bad, "state_mismatch")
272
+ for h in homes:
273
+ bad = {"state:" + s: 1 for s in spec.STATES if s != h}
274
+ conj("mm:home_" + h, "home:" + h, bad, "state_mismatch")
275
+
276
+ # volume/year curves: relu(+-(year - curve(vol)) - tol), gated by reporter and has_year
277
+ for k in curves:
278
+ c = spec.CURVES[k]
279
+ a, coef = knot_readout(c["pts"], c["vmax"])
280
+ gate = merge(sat("is:" + k, GATE), sat("has_year", GATE), {"has_volume": GATE})
281
+ late = {n: -w for n, w in coef.items()}
282
+ late["year"], late["year_hi"] = 1.0, -1.0
283
+ for n, w in gate.items():
284
+ late[n] = late.get(n, 0) + w
285
+ cut, extra = spec.EARLY_TOL.get(k, (None, 0.0))
286
+ if cut: # tol(v) = tol + extra * (cut - min(v, cut)) / cut, with min(v, cut) = v - relu(v - cut)
287
+ widen = {"volume3": extra / cut, "vol>%d" % cut: -extra / cut}
288
+ for n, w in widen.items():
289
+ late[n] = late.get(n, 0) + w
290
+ L2.add("late:" + k, late, bias=-a - c["tol"] - extra - 3 * GATE)
291
+ early = dict(coef)
292
+ early["year"], early["year_hi"] = -1.0, 1.0
293
+ for n, w in gate.items():
294
+ early[n] = early.get(n, 0) + w
295
+ if cut:
296
+ for n, w in widen.items():
297
+ early[n] = early.get(n, 0) + w
298
+ L2.add("early:" + k, early, bias=a - c["tol"] - extra - 3 * GATE)
299
+ meta["groups"].setdefault("volume_year", []).extend(["late:" + k, "early:" + k])
300
+ L2.add("over:" + k, merge({"volume3": 1.0, "expl": -VGATE}, sat("is:" + k, VGATE)), bias=-c["vmax"] - VGATE)
301
+ L2.add("over4:" + k, merge({"has_vol4": 1.0, "expl": -2.0}, sat("is:" + k, 1.0)), bias=-1.0)
302
+ meta["groups"].setdefault("volume_missing", []).extend(["over:" + k, "over4:" + k])
303
+ # WL / Lexis: the 4-digit "volume" is the year the database added the opinion:
304
+ # the decision year, or the next year for late-December opinions
305
+ for src in ("wl", "lexis"):
306
+ g = merge(sat("is:" + src, GATE), sat("has_year", GATE))
307
+ L2.add("late:" + src, merge({"year": 1.0, "year_hi": -1.0, "vol4y": -1.0, "vol4y_hi": 1.0}, g), bias=-0.5 - 2 * GATE)
308
+ L2.add("early:" + src, merge({"year": -1.0, "year_hi": 1.0, "vol4y": 1.0, "vol4y_hi": -1.0}, g), bias=-1.5 - 2 * GATE)
309
+ meta["groups"]["volume_year"].extend(["late:" + src, "early:" + src])
310
+ for p in ("phantom:4th", "phantom:5th", "phantom:6th+"):
311
+ L2.add("r:" + p, {p: 1})
312
+ meta["groups"].setdefault("phantom_series", []).append("r:" + p)
313
+ L2.add("r:future", {"future": 1})
314
+ meta["groups"]["future_year"] = ["r:future"]
315
+ L2.add("r:unknown", {"has_volume": 1, "known_source": -1})
316
+ meta["groups"]["unknown_reporter"] = ["r:unknown"]
317
+ names2, W2, b2 = L2.tensors()
318
+ P["fc2.weight"], P["fc2.bias"] = W2, b2
319
+ meta["rules"] = names2
320
+
321
+ # ---- fc3: evidence sums and their overflow above cap; fc4: logit
322
+ L3 = Layer(names2)
323
+ for ev, w, cap, _ in spec.EVIDENCE:
324
+ terms = {n: 1.0 for n in meta["groups"][ev]}
325
+ L3.add("ev:" + ev, terms)
326
+ L3.add("ovf:" + ev, terms, bias=-cap)
327
+ names3, W3, b3 = L3.tensors()
328
+ P["fc3.weight"], P["fc3.bias"] = W3, b3
329
+
330
+ # saturated evidence = ev - ovf (a fixed difference, written as a linear layer)
331
+ E = len(spec.EVIDENCE)
332
+ Ws = np.zeros((E, len(names3)), np.float32)
333
+ for i, (ev, *_rest) in enumerate(spec.EVIDENCE):
334
+ Ws[i, names3.index("ev:" + ev)] = 1
335
+ Ws[i, names3.index("ovf:" + ev)] = -1
336
+ P["fc_sat.weight"], P["fc_sat.bias"] = Ws, np.zeros(E, np.float32)
337
+ P["fc4.weight"] = np.array([[w for _, w, _, _ in spec.EVIDENCE]], np.float32)
338
+ P["fc4.bias"] = np.array([spec.BIAS], np.float32)
339
+ meta["evidence"] = [e[0] for e in spec.EVIDENCE]
340
+ return P, meta
341
+
342
+
343
+ class CiteNet(nn.Module):
344
+ def __init__(self, P):
345
+ super().__init__()
346
+ def conv(name):
347
+ w = torch.from_numpy(P[name + ".weight"])
348
+ m = nn.Conv2d(w.shape[1], w.shape[0], (1, w.shape[3]))
349
+ return m
350
+ def fc(name):
351
+ w = torch.from_numpy(P[name + ".weight"])
352
+ return nn.Linear(w.shape[1], w.shape[0])
353
+ self.conv_rep, self.conv_float, self.conv_court = conv("conv_rep"), conv("conv_float"), conv("conv_court")
354
+ self.conv_gate, self.conv_val = conv("conv_gate"), conv("conv_val")
355
+ self.fc1, self.fc2, self.fc3 = fc("fc1"), fc("fc2"), fc("fc3")
356
+ self.fc_sat, self.fc4 = fc("fc_sat"), fc("fc4")
357
+ self.register_buffer("alphabet", torch.arange(spec.NCHAR, dtype=torch.float32).view(1, -1, 1, 1))
358
+ self.load_weights(P)
359
+
360
+ def load_weights(self, P):
361
+ sd = {k: torch.from_numpy(np.ascontiguousarray(v)) for k, v in P.items()}
362
+ sd["alphabet"] = self.alphabet
363
+ self.load_state_dict(sd)
364
+
365
+ def forward(self, codes):
366
+ x = F.relu(1.0 - torch.abs(codes - self.alphabet)) # one-hot [B,48,1,L]
367
+ xa = F.relu(1.0 - torch.abs(codes[:, :, :, :ANCHOR_W] - self.alphabet)) # slice the codes, not the one-hot
368
+ rep = F.relu(self.conv_rep(xa)).amax(dim=(2, 3))
369
+ flt = F.relu(self.conv_float(x)).amax(dim=(2, 3))
370
+ court = F.relu(self.conv_court(x)).amax(dim=(2, 3))
371
+ gate = F.relu(self.conv_gate(x))
372
+ num = (self.conv_val(x) * gate).amax(dim=(2, 3))
373
+ gate = gate.amax(dim=(2, 3))
374
+ f = torch.cat([rep, flt, court, num, gate], dim=1)
375
+ concepts = F.relu(self.fc1(f))
376
+ rules = F.relu(self.fc2(concepts))
377
+ evidence = self.fc_sat(F.relu(self.fc3(rules)))
378
+ p_fake = torch.sigmoid(self.fc4(evidence))
379
+ return p_fake, evidence, concepts
380
+
381
+
382
+ def make():
383
+ P, meta = build()
384
+ net = CiteNet(P).eval()
385
+ return net, meta
386
+
387
+
388
+ def param_counts(net):
389
+ total = sum(p.numel() for p in net.parameters())
390
+ nonzero = sum(int((p != 0).sum()) for p in net.parameters())
391
+ return total, nonzero
citenet/spec.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hand-written knowledge that net.py compiles into weights.
2
+
3
+ Nothing in this file is learned. Every table below is written from general
4
+ knowledge of U.S. citation practice (Bluebook forms, West's National Reporter
5
+ System, approximate reporter volume/year timelines). net.py turns each entry
6
+ into specific conv filters and ReLU neurons, so this file is the model's
7
+ source code.
8
+ """
9
+
10
+ # ---------------------------------------------------------------- alphabet
11
+ PAD, BOS, EOS = 0, 1, 2
12
+ DIGIT0 = 3 # '0'..'9' -> 3..12
13
+ LETTER_A = 13 # 'a'..'z' -> 13..38
14
+ SPACE, DOT, COMMA, LPAREN, RPAREN, APOS, AMP, DASH, OTHER = range(39, 48)
15
+ NCHAR = 48
16
+ SEQ = 64 # BOS + up to 46 chars + EOS, then PAD (room for K-1 pad)
17
+ MAXCHARS = 46
18
+
19
+ PUNCT = {" ": SPACE, ".": DOT, ",": COMMA, "(": LPAREN, ")": RPAREN,
20
+ "'": APOS, "&": AMP, "-": DASH}
21
+
22
+ DIGITS = set(range(DIGIT0, DIGIT0 + 10))
23
+ LETTERS = set(range(LETTER_A, LETTER_A + 26))
24
+ ALL = set(range(NCHAR))
25
+ NONDIGIT = ALL - DIGITS
26
+
27
+
28
+ def code(ch):
29
+ if ch.isdigit():
30
+ return DIGIT0 + int(ch)
31
+ if "a" <= ch <= "z":
32
+ return LETTER_A + ord(ch) - ord("a")
33
+ return PUNCT.get(ch, OTHER)
34
+
35
+
36
+ # ---------------------------------------------------------------- template DSL
37
+ # A template is a string. Plain characters match themselves; these match a class:
38
+ # # any digit _ space ! any non-digit (incl. BOS/EOS/PAD)
39
+ # < BOS ~ end of token: space, EOS or ','
40
+ # @ letter or '.' % digit, 'a' or '*' (page, "at" short form, star page)
41
+ # {tdh} any of the listed characters
42
+ CLASSES = {
43
+ "#": DIGITS, "_": {SPACE}, "!": NONDIGIT, "<": {BOS},
44
+ "~": {SPACE, EOS, COMMA}, "@": LETTERS | {DOT},
45
+ "%": DIGITS | {code("a"), code("*")},
46
+ }
47
+
48
+
49
+ def parse_template(t):
50
+ """Template string -> list of allowed-code sets, one per offset."""
51
+ out, i = [], 0
52
+ while i < len(t):
53
+ ch = t[i]
54
+ if ch == "{":
55
+ j = t.index("}", i)
56
+ out.append({code(c) for c in t[i + 1:j]})
57
+ i = j + 1
58
+ continue
59
+ out.append(CLASSES[ch] if ch in CLASSES else {code(ch)})
60
+ i += 1
61
+ return out
62
+
63
+
64
+ # ---------------------------------------------------------------- reporters
65
+ # (normalized abbreviation, class, home state or region, curve key)
66
+ # Classes:
67
+ # SCOTUS U.S. Supreme Court reporters: court parenthetical holds only a year
68
+ # FEDAPP modern federal appellate reporters: circuit courts only
69
+ # FEDAPP_OLD F. / F.2d: early volumes also printed district-court opinions
70
+ # FEDDIST federal district-court reporters
71
+ # BANKR Bankruptcy Reporter
72
+ # FEDSPEC other federal (claims court, Federal Cases)
73
+ # REGION West regional reporter; the 'home' field is the region key
74
+ # STATE a state's own reporter; the 'home' field is the state
75
+ # WL Westlaw / Lexis database cites: the "volume" is the year
76
+ REPORTERS = [
77
+ ("u.s.", "SCOTUS", None, "us"), ("us_#", "SCOTUS", None, "us"), ("us_at_", "SCOTUS", None, "us"),
78
+ ("s.ct.", "SCOTUS", None, "sct"), ("s ct", "SCOTUS", None, "sct"),
79
+ ("dall.", "SCOTUS", None, None), ("cranch", "SCOTUS", None, None),
80
+ ("wheat.", "SCOTUS", None, None), ("pet.", "SCOTUS", None, None),
81
+ ("how.", "SCOTUS", None, None), ("black", "SCOTUS", None, None),
82
+ ("wall.", "SCOTUS", None, None),
83
+ ("l.ed.", "SCOTUS", None, "led"), ("l.ed.2d", "SCOTUS", None, "led2"),
84
+
85
+ ("f.", "FEDAPP_OLD", None, "f1"), ("f.2d", "FEDAPP_OLD", None, "f2"),
86
+ ("f.3d", "FEDAPP", None, "f3"), ("f.4th", "FEDAPP", None, "f4"),
87
+ ("f.app'x", "FEDAPP", None, "fappx"), ("f.appx", "FEDAPP", None, "fappx"),
88
+ ("fed.appx.", "FEDAPP", None, "fappx"), ("fed.appx", "FEDAPP", None, "fappx"),
89
+ ("fed.app'x", "FEDAPP", None, "fappx"), ("fed.app.", "FEDAPP", None, "fappx"),
90
+
91
+ ("f.supp.", "FEDDIST", None, "fs1"), ("f.supp_#", "FEDDIST", None, "fs1"), ("f.supp_at_", "FEDDIST", None, "fs1"),
92
+ ("f.supp.2d", "FEDDIST", None, "fs2"), ("f.supp.3d", "FEDDIST", None, "fs3"),
93
+ ("f.supp 2d", "FEDDIST", None, "fs2"), ("f.supp 3d", "FEDDIST", None, "fs3"), # "F. Supp 2d"
94
+ ("f.r.d.", "FEDDIST", None, "frd"),
95
+ ("b.r.", "BANKR", None, "br"),
96
+ ("fed.cl.", "FEDSPEC", None, None), ("ct.cl.", "FEDSPEC", None, None),
97
+ ("cl.ct.", "FEDSPEC", None, None), ("f.cas.", "FEDSPEC", None, None),
98
+ ("ct.int'l", "FEDSPEC", None, None), ("m.j.", "FEDSPEC", None, None),
99
+
100
+ ("a.", "REGION", "A", None), ("a.2d", "REGION", "A", "a2"), ("a.3d", "REGION", "A", "a3"),
101
+ ("n.e.", "REGION", "NE", None), ("n.e.2d", "REGION", "NE", "ne2"), ("n.e.3d", "REGION", "NE", "ne3"),
102
+ ("n.w.", "REGION", "NW", None), ("n.w.2d", "REGION", "NW", "nw2"), ("n.w.3d", "REGION", "NW", None),
103
+ ("p.", "REGION", "P", None), ("p", "REGION", "P", None),
104
+ ("p.2d", "REGION", "P", "p2"), ("p.3d", "REGION", "P", "p3"),
105
+ ("s.e.", "REGION", "SE", None), ("s.e.2d", "REGION", "SE", "se2"), ("s.e.3d", "REGION", "SE", None),
106
+ ("s.w.", "REGION", "SW", None), ("s.w.2d", "REGION", "SW", "sw2"), ("s.w.3d", "REGION", "SW", "sw3"),
107
+ ("so.", "REGION", "SO", None), ("so.2d", "REGION", "SO", "so2"), ("so.3d", "REGION", "SO", "so3"),
108
+
109
+ ("cal.rptr.", "STATE", "cal", "cr1"), ("cal.rptr.2d", "STATE", "cal", "cr2"),
110
+ ("cal.rptr.3d", "STATE", "cal", "cr3"),
111
+ ("cal.", "STATE", "cal", None), ("cal.2d", "STATE", "cal", None), ("cal.3d", "STATE", "cal", None),
112
+ ("cal.4th", "STATE", "cal", None), ("cal.5th", "STATE", "cal", None),
113
+ ("cal.app.", "STATE", "cal", None), ("cal.app.2d", "STATE", "cal", None),
114
+ ("cal.app.3d", "STATE", "cal", None), ("cal.app.4th", "STATE", "cal", None),
115
+ ("cal.app.5th", "STATE", "cal", None),
116
+ ("n.y.s.", "STATE", "n.y", None), ("n.y.s.2d", "STATE", "n.y", "nys2"),
117
+ ("n.y.s.3d", "STATE", "n.y", "nys3"),
118
+ ("n.y.", "STATE", "n.y", None), ("n.y.2d", "STATE", "n.y", None), ("n.y.3d", "STATE", "n.y", None),
119
+ ("a.d.", "STATE", "n.y", None), ("a.d.2d", "STATE", "n.y", None), ("a.d.3d", "STATE", "n.y", None),
120
+ ("misc.2d", "STATE", "n.y", None), ("misc.3d", "STATE", "n.y", None),
121
+ ("mass.", "STATE", "mass", None), ("mass.app.ct.", "STATE", "mass", None),
122
+ ("mass.app.", "STATE", "mass", None),
123
+ ("ill.", "STATE", "ill", None), ("ill.2d", "STATE", "ill", None), ("ill.app.", "STATE", "ill", None),
124
+ ("ill.app.2d", "STATE", "ill", None), ("ill.app.3d", "STATE", "ill", None),
125
+ ("ill.dec.", "STATE", "ill", None), ("ind.", "STATE", "ind", None),
126
+ ("ind.app.", "STATE", "ind", None), ("okla.", "STATE", "okla", None),
127
+ ("va.", "STATE", "va", None), ("va.app.", "STATE", "va", None), ("va.cir.", "STATE", "va", None),
128
+ ("pa.", "STATE", "pa", None), ("pa.super.", "STATE", "pa", None), ("pa.commw.", "STATE", "pa", None),
129
+ ("n.j.", "STATE", "n.j", None), ("n.j.super.", "STATE", "n.j", None),
130
+ ("wash.", "STATE", "wash", None), ("wash.2d", "STATE", "wash", None),
131
+ ("wash.app.", "STATE", "wash", None), ("wash.app.2d", "STATE", "wash", None),
132
+ ("ohio st.3d", "STATE", "ohio", None), ("ohio app.3d", "STATE", "ohio", None),
133
+ ("md.", "STATE", "md", None), ("md.app.", "STATE", "md", None),
134
+ ("n.c.", "STATE", "n.c", None), ("n.c.app.", "STATE", "n.c", None),
135
+ ("ga.", "STATE", "ga", None), ("ga.app.", "STATE", "ga", None),
136
+ ("kan.", "STATE", "kan", None), ("kan.app.", "STATE", "kan", None),
137
+ ("kan.app.2d", "STATE", "kan", None),
138
+ ("mich.", "STATE", "mich", None), ("mich.app.", "STATE", "mich", None),
139
+ ("wis.2d", "STATE", "wis", None), ("conn.", "STATE", "conn", None),
140
+ ("conn.app.", "STATE", "conn", None), ("tex.", "STATE", "tex", None),
141
+ ("s.c.", "STATE", "s.c", None), ("r.i.", "STATE", "r.i", None),
142
+ ("colo.", "STATE", "colo", None), ("ariz.", "STATE", "ariz", None),
143
+ ("or.", "STATE", "or", None), ("or.app.", "STATE", "or", None),
144
+
145
+ # v1.5: state official reports, period-free official styles, compact West abbreviations
146
+ ("wn.", "STATE", "wash", None), ("wn.2d", "STATE", "wash", None), ("wn.app.", "STATE", "wash", None),
147
+ ("wn.app.2d", "STATE", "wash", None),
148
+ ("neb.", "STATE", "neb", None), ("neb.app.", "STATE", "neb", None), ("n.m.", "STATE", "n.m", None),
149
+ ("nev.", "STATE", "nev", None), ("minn.", "STATE", "minn", None), ("ark.", "STATE", "ark", None),
150
+ ("ark.app.", "STATE", "ark", None), ("idaho", "STATE", "idaho", None), ("mont.", "STATE", "mont", None),
151
+ ("ohio st.", "STATE", "ohio", None), ("ohio st.2d", "STATE", "ohio", None), ("ohio app.", "STATE", "ohio", None),
152
+ ("ohio app.2d", "STATE", "ohio", None), ("ala.", "STATE", "ala", None), ("ala.app.", "STATE", "ala", None),
153
+ ("la.", "STATE", "la", None), ("iowa", "STATE", "iowa", None), ("miss.", "STATE", "miss", None),
154
+ ("wis.", "STATE", "wis", None), ("w.va.", "STATE", "w.va", None), ("vt.", "STATE", "vt", None),
155
+ ("mo.", "STATE", "mo", None), ("mo.app.", "STATE", "mo", None), ("n.h.", "STATE", "n.h", None),
156
+ ("ky.", "STATE", "ky", None), ("tenn.", "STATE", "tenn", None), ("tenn.app.", "STATE", "tenn", None),
157
+ ("del.", "STATE", "del", None), ("me.", "STATE", "me", None), ("haw.", "STATE", "haw", None),
158
+ ("wyo.", "STATE", "wyo", None), ("utah", "STATE", "utah", None), ("utah 2d", "STATE", "utah", None),
159
+ ("n.d.", "STATE", "n.d", None), ("s.d.", "STATE", "s.d", None), ("alaska", "STATE", "alaska", None),
160
+ ("ny", "STATE", "n.y", None), ("ny2d", "STATE", "n.y", None), ("ny3d", "STATE", "n.y", None),
161
+ ("n y 2d", "STATE", "n.y", None), ("ad2d", "STATE", "n.y", None), ("ad3d", "STATE", "n.y", None),
162
+ ("a d 2d", "STATE", "n.y", None), ("app.div.", "STATE", "n.y", None), ("app div", "STATE", "n.y", None),
163
+ ("app.div.2d", "STATE", "n.y", None), ("misc.", "STATE", "n.y", None), ("misc 2d", "STATE", "n.y", None),
164
+ ("misc 3d", "STATE", "n.y", None), ("misc.rep.", "STATE", "n.y", None), ("nys2d", "STATE", "n.y", "nys2"),
165
+ ("nys3d", "STATE", "n.y", "nys3"),
166
+ ("f2d", "FEDAPP_OLD", None, "f2"), ("f3d", "FEDAPP", None, "f3"), ("f4th", "FEDAPP", None, "f4"),
167
+ ("a2d", "REGION", "A", "a2"), ("a3d", "REGION", "A", "a3"), ("ne2d", "REGION", "NE", "ne2"),
168
+ ("ne3d", "REGION", "NE", "ne3"), ("nw2d", "REGION", "NW", "nw2"), ("p2d", "REGION", "P", "p2"),
169
+ ("p3d", "REGION", "P", "p3"), ("se2d", "REGION", "SE", "se2"), ("sw2d", "REGION", "SW", "sw2"),
170
+ ("sw3d", "REGION", "SW", "sw3"), ("so2d", "REGION", "SO", "so2"), ("so3d", "REGION", "SO", "so3"),
171
+
172
+ ("wl", "WL", None, "wl"),
173
+ ]
174
+
175
+ # A template that only counts when none of these also fired ("f.supp 2" is a prefix of "f.supp 2d").
176
+ EXCLUDES = {"f.supp_#": ["f.supp 2d", "f.supp 3d"]}
177
+
178
+ # Generic series detectors. A series ordinal glued to an abbreviation and then
179
+ # followed by a page ("x.4th 12") or a short form ("x.4th at"). "4th" and "5th"
180
+ # are only real for these reporters; 6th..9th are never real.
181
+ VALID_4TH = ["f.4th", "f4th", "cal.4th", "cal.app.4th"]
182
+ VALID_5TH = ["cal.5th", "cal.app.5th"]
183
+ # "f.5th 12", "f.5th at", "f.5th, at 12", "f.5th *12"; and "utah 4th 279" (no period before the ordinal)
184
+ PHANTOM_SERIES = {}
185
+ for _k, _o in (("ord4", "4"), ("ord5", "5"), ("ord6+", "{6789}")):
186
+ PHANTOM_SERIES.update({_k: "@" + _o + "th_%", _k + "s": "@_" + _o + "th_%", # "f.5th 12" / "utah 4th 279"
187
+ _k + "c": "@" + _o + "th,_%", _k + "sc": "@_" + _o + "th,_%"}) # "f.5th, at 12"
188
+
189
+ # Non-case sources and database cites that should not be marked unknown.
190
+ OTHER_SOURCES = {
191
+ "lexis": "lexis~", "fed.reg.": "#_fed.reg.~", "fr": "#_fr~",
192
+ "u.s.c.": "#_u.s.c.", "c.f.r.": "#_c.f.r.", "stat.": "#_stat.~",
193
+ "ferc": "#_ferc~", "i&n": "n.dec.~", "t.c.": "#_t.c.",
194
+ "law review": "l.rev.~", "law journal": "l.j.~", "u.s.app.d.c.": "u.s.app.d.c.",
195
+ "c.m.r.": "#_c.m.r.~", "cmr": "#_cmr~", "vet.app.": "#_vet.app.~",
196
+ "b.t.a.": "#_b.t.a.~", "fed.cir.": "#_fed.cir.~", "c.c.p.a.": "#_c.c.p.a.~",
197
+ "o.s.": "#_o.s.~", "ok neutral": "#_ok_", "ok civ app": "#_ok civ app", "op.o.l.c.": "#_op.o.l.c.~",
198
+ "op.att'y": "#_op.att'y", "a.l.r.": "#_a.l.r.", # A.L.R. runs 1st..7th and Fed.
199
+ # v1.5: non-case sources that turn up constantly in opinions
200
+ "ilcs": "#_ilcs~", "nlrb": "#_nlrb~", "uscma": "#_uscma~", "cong.rec.": "#_cong.rec.~", "l.r.a.": "#_l.r.a.",
201
+ "r.s.": "#_r.s.~", "burns": "#_burns~", "ops.cal.atty": "#_ops.cal.atty", "d.p.r.": "#_d.p.r.~", "dpr": "#_dpr~",
202
+ "j.t.s.": "#_j.t.s.~", "mal": "#_mal~", "ut neutral": "#_ut_", "ut app": "#_ut app", "nd neutral": "#_nd_",
203
+ "ny slip op": "#_ny slip op", "johns.": "#_johns.", "wend.": "#_wend.~", "cow.": "#_cow.~", "barb.": "#_barb.~",
204
+ "hun": "#_hun~", "paige": "#_paige~", "pick.": "#_pick.~", "cush.": "#_cush.~", "met.": "#_met.~",
205
+ "gray": "#_gray~", "allen": "#_allen~", "binn.": "#_binn.~", "watts": "#_watts~",
206
+ }
207
+
208
+ # ---------------------------------------------------------------- courts
209
+ # West regions. N.D. and S.D. are left out on purpose: "(N.D. 2005)" and
210
+ # "(N.D. Cal. 2005)" share a prefix, and the district detector owns that prefix.
211
+ REGION_STATES = {
212
+ "A": ["conn", "del", "me", "md", "n.h", "n.j", "pa", "r.i", "vt", "d.c"],
213
+ "NE": ["ill", "ind", "mass", "n.y", "ohio"],
214
+ "NW": ["iowa", "mich", "minn", "neb", "wis"],
215
+ "P": ["alaska", "ariz", "cal", "colo", "haw", "idaho", "kan", "mont", "nev",
216
+ "n.m", "okla", "or", "utah", "wash", "wyo"],
217
+ "SE": ["ga", "n.c", "s.c", "va", "w.va"],
218
+ "SW": ["ark", "ky", "mo", "tenn", "tex"],
219
+ "SO": ["ala", "fla", "la", "miss"],
220
+ }
221
+ STATES = [s for v in REGION_STATES.values() for s in v]
222
+ UNDOTTED = {"ohio", "iowa", "utah", "idaho", "alaska"}
223
+
224
+
225
+ def state_template(s):
226
+ if s == "d.c":
227
+ return "(d.c.{ 0123456789}" # D.C. Court of Appeals: "(D.C. 2005)", "(D.C.2005)"
228
+ if s in UNDOTTED:
229
+ return "(" + s + "{ .}"
230
+ return "(" + s + "."
231
+
232
+
233
+ COURTS = {
234
+ # federal courts of appeals: an ordinal ending in t/d/h then " cir."
235
+ "circuit": "{tdh}_cir{.c }", "circuit_dc": "d.c.cir{. }", "circuit_fed": "fed.cir{. }",
236
+ # district courts: "(N.D. Cal.", "(S.D.N.Y.", "(D. Mass." -> "(d.mass."
237
+ "district_x": "({nsewmc}.d.@", "district_d": "(d.@",
238
+ "bankruptcy": "bankr.", "bap": "b.a.p.",
239
+ "year_only": "(####)",
240
+ # parentheticals that hold other citations: explanatory, parallel cites, subsequent history
241
+ "expl_quoting": "(quoting", "expl_citing": "(citing", "expl_internal": "(interna",
242
+ "expl_alteration": "(alterat", "expl_emphasis": "(emphasi", "expl_quotation": "(quotati",
243
+ "expl_par1": "(#_", "expl_par2": "(##_", "expl_par3": "(###_",
244
+ "expl_affirmed": "(affirme", "expl_affd": "(aff'd", "expl_revd": "(rev'd", "expl_cert": "(cert",
245
+ "expl_vacated": "(vacated", "expl_overruled": "(overrul", "expl_abrogated": "(abrogat",
246
+ "expl_superseded": "(superse", "expl_modified": "(modifie",
247
+ }
248
+ COURTS.update({"state_" + s: state_template(s) for s in STATES})
249
+ COURTS.update({"dc_app": "(d.c.app", "dc_mun": "(d.c.mun"}) # "(D.C. App. 1968)", "(D.C. Mun. App. 1950)"
250
+
251
+ # ---------------------------------------------------------------- volume -> year
252
+ # Approximate (volume, decision year) anchors, written from memory of landmark
253
+ # cases and series start/end dates (e.g. 347 U.S. = 1954, 467 U.S. = 1984,
254
+ # F.3d runs 1993-2021). tol is the allowed gap in years before a cite looks
255
+ # off; vmax is the highest volume that exists (2026).
256
+ CURVES = {
257
+ # 1-4 U.S. (Dallas) span 1754-1806, so early volumes get extra tolerance (see EARLY_TOL)
258
+ "us": dict(tol=2.5, vmax=612, pts=[
259
+ (1, 1785), (2, 1792), (3, 1797), (4, 1801), (5, 1803), (9, 1809), (13, 1815), (17, 1819), (22, 1824), (60, 1857), (83, 1873),
260
+ (100, 1879), (118, 1886), (163, 1896), (198, 1905), (249, 1919), (268, 1925),
261
+ (304, 1938), (323, 1944), (347, 1954), (372, 1963), (384, 1966), (410, 1973),
262
+ (438, 1978), (467, 1984), (490, 1989), (505, 1992), (521, 1997), (531, 2000),
263
+ (543, 2005), (554, 2008), (558, 2010), (567, 2012), (576, 2015), (585, 2018),
264
+ (590, 2020), (597, 2022), (603, 2024), (612, 2027)]),
265
+ "sct": dict(tol=1.5, vmax=147, pts=[(1, 1880.5), (147, 2026.5)]), # Oct-Term volume v spans v+1879..v+1880
266
+ "led": dict(tol=4, vmax=100, pts=[
267
+ (1, 1795), (2, 1805), (3, 1812), (4, 1818), (5, 1821), (6, 1825), (7, 1828), (8, 1832),
268
+ (9, 1835.5), (10, 1839), (15, 1857), (21, 1873), (41, 1896), (49, 1905), (63, 1919), (82, 1938),
269
+ (98, 1954), (100, 1956)]),
270
+ "led2": dict(tol=2.5, vmax=226, pts=[
271
+ (1, 1956), (16, 1966), (35, 1973), (81, 1984), (131, 1995), (167, 2007), (173, 2009),
272
+ (192, 2015), (213, 2022), (226, 2026)]),
273
+ "f1": dict(tol=3, vmax=300, pts=[(1, 1880), (100, 1900), (200, 1912), (300, 1924)]),
274
+ "f2": dict(tol=2.5, vmax=999, pts=[
275
+ (1, 1924), (50, 1931), (100, 1938), (150, 1945), (200, 1952), (250, 1957),
276
+ (300, 1962), (350, 1965), (400, 1968), (450, 1971), (500, 1974), (550, 1977),
277
+ (600, 1979), (650, 1981), (700, 1983), (750, 1985), (800, 1986), (850, 1988),
278
+ (900, 1990), (950, 1991), (999, 1993)]),
279
+ "f3": dict(tol=2, vmax=999, pts=[
280
+ (1, 1993), (50, 1995), (100, 1996.5), (150, 1998), (200, 1999.5), (250, 2001),
281
+ (300, 2002), (350, 2003.5), (400, 2005), (450, 2006), (500, 2007), (550, 2008.5),
282
+ (600, 2010), (650, 2011), (700, 2012.5), (750, 2014), (800, 2015), (850, 2017),
283
+ (900, 2018), (950, 2020), (999, 2021)]),
284
+ "f4": dict(tol=1.5, vmax=185, pts=[(1, 2021), (50, 2022.7), (100, 2024), (150, 2025.4), (200, 2026.8)]),
285
+ "fs1": dict(tol=3, vmax=999, pts=[
286
+ (1, 1932), (100, 1951), (200, 1962), (300, 1969), (400, 1975), (500, 1980),
287
+ (600, 1984), (700, 1988), (800, 1992), (900, 1995), (999, 1998)]),
288
+ "fs2": dict(tol=2, vmax=999, pts=[
289
+ (1, 1998), (100, 2000), (200, 2002), (300, 2004), (400, 2005.5), (500, 2007),
290
+ (600, 2009), (700, 2010), (800, 2011), (900, 2012.5), (999, 2014)]),
291
+ "fs3": dict(tol=2.5, vmax=850, pts=[
292
+ (1, 2014), (100, 2015), (200, 2016.5), (300, 2018), (400, 2019.5), (500, 2020.5),
293
+ (600, 2022), (700, 2023.5), (800, 2025)]),
294
+ "fappx": dict(tol=2, vmax=900, pts=[
295
+ (1, 2001), (100, 2004), (200, 2006.5), (300, 2008), (400, 2010.5), (500, 2012.5),
296
+ (600, 2015), (700, 2017), (800, 2019.5), (860, 2021), (900, 2022)]),
297
+ "br": dict(tol=3, vmax=700, pts=[
298
+ (1, 1979), (100, 1989), (200, 1996), (300, 2003), (400, 2009), (500, 2013),
299
+ (600, 2019), (660, 2024), (700, 2027)]),
300
+ "frd": dict(tol=4, vmax=360, pts=[(1, 1940), (50, 1970), (100, 1983.5), (150, 1993), (200, 2001), (250, 2008),
301
+ (300, 2014), (340, 2022), (360, 2026)]),
302
+ "a2": dict(tol=3, vmax=999, pts=[
303
+ (1, 1938), (100, 1953), (200, 1964), (300, 1973), (400, 1979), (500, 1985),
304
+ (600, 1991), (700, 1997), (800, 2002), (900, 2006), (999, 2010)]),
305
+ "a3": dict(tol=3, vmax=340, pts=[(1, 2010), (100, 2014), (200, 2019), (300, 2024), (340, 2026)]),
306
+ "ne2": dict(tol=3, vmax=999, pts=[
307
+ (1, 1936), (100, 1951), (200, 1964), (300, 1973), (400, 1980), (500, 1986.5),
308
+ (600, 1992), (700, 1998), (800, 2003.5), (900, 2009), (999, 2014)]),
309
+ "ne3": dict(tol=3, vmax=290, pts=[(1, 2014), (100, 2018), (200, 2022), (290, 2026)]),
310
+ "nw2": dict(tol=3, vmax=999, pts=[
311
+ (1, 1942), (100, 1960), (200, 1972), (300, 1980.5), (400, 1987), (500, 1993),
312
+ (600, 1999.5), (700, 2005), (800, 2011), (900, 2017), (999, 2023)]),
313
+ "p2": dict(tol=3, vmax=999, pts=[
314
+ (1, 1931), (100, 1940), (200, 1949), (300, 1956), (400, 1965), (500, 1972),
315
+ (600, 1979), (700, 1985), (800, 1990.5), (900, 1995), (999, 2000)]),
316
+ "p3": dict(tol=3, vmax=590, pts=[(1, 2000), (100, 2004.5), (200, 2009), (300, 2013), (400, 2017), (500, 2021.5), (590, 2026)]),
317
+ "se2": dict(tol=3, vmax=930, pts=[
318
+ (1, 1939), (100, 1957.5), (200, 1973.5), (300, 1983), (400, 1991), (500, 1998),
319
+ (600, 2004), (700, 2010.5), (800, 2017), (900, 2023), (930, 2026)]),
320
+ "sw2": dict(tol=3, vmax=999, pts=[
321
+ (1, 1928), (100, 1937), (200, 1947), (300, 1957), (400, 1966), (500, 1973.5),
322
+ (600, 1980), (700, 1985.5), (800, 1990.5), (900, 1995), (999, 1999)]),
323
+ "sw3": dict(tol=3, vmax=740, pts=[(1, 1999), (100, 2003), (200, 2006), (300, 2009.5), (400, 2013), (500, 2016), (600, 2020), (700, 2024), (740, 2026)]),
324
+ "so2": dict(tol=3, vmax=999, pts=[
325
+ (1, 1941), (100, 1958), (200, 1967), (300, 1974), (400, 1981), (500, 1987),
326
+ (600, 1992), (700, 1997), (800, 2001.5), (900, 2005), (999, 2008)]),
327
+ "so3": dict(tol=3, vmax=430, pts=[(1, 2008), (100, 2012.5), (200, 2016), (300, 2020.5), (400, 2025), (430, 2026)]),
328
+ "cr1": dict(tol=3, vmax=286, pts=[(1, 1959), (100, 1972), (200, 1984), (286, 1991)]),
329
+ "cr2": dict(tol=2, vmax=135, pts=[(1, 1991), (50, 1996), (100, 2000), (135, 2003)]),
330
+ "cr3": dict(tol=3, vmax=345, pts=[(1, 2003), (100, 2009), (200, 2016), (300, 2022), (345, 2026)]),
331
+ "nys2": dict(tol=3, vmax=999, pts=[
332
+ (1, 1938), (100, 1950), (200, 1960), (300, 1969), (400, 1977.5), (500, 1986),
333
+ (600, 1993.5), (700, 1999.5), (800, 2005), (900, 2010), (999, 2014)]),
334
+ "nys3": dict(tol=3, vmax=240, pts=[(1, 2014), (100, 2019), (200, 2024), (240, 2026)]),
335
+ }
336
+ LAST_YEAR = 2026 # anything later is in the future
337
+
338
+ # Extra tolerance for the first volumes of the oldest reporters: tol + extra * (cut - v) / cut for v < cut
339
+ EARLY_TOL = {"us": (10, 20.0), "led": (10, 6.0)}
340
+
341
+ # Shared "volume thermometer": every curve is read out of these ReLU knots.
342
+ KNOTS = [0, 2, 4, 6, 8, 10, 15, 20, 30, 40, 50, 60, 75, 90, 100, 125, 150, 175, 200, 250, 300, 350,
343
+ 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950, 1000]
344
+
345
+ # ---------------------------------------------------------------- evidence -> logit
346
+ # Each evidence neuron saturates at `cap`; the logit is bias + sum(weight * value).
347
+ EVIDENCE = [
348
+ # name, weight, cap, meaning
349
+ ("phantom_series", 9.0, 1.0, "series that was never published (F.5th, S.W.4th, ...)"),
350
+ ("court_mismatch", 7.0, 1.0, "reporter never prints that court's opinions"),
351
+ ("state_mismatch", 7.0, 1.0, "regional or state reporter does not cover that state"),
352
+ ("volume_year", 2.5, 3.0, "years between the cited year and that volume's timeline"),
353
+ ("volume_missing", 7.0, 1.0, "volume number beyond the last one published"),
354
+ ("future_year", 7.0, 1.0, "decided after " + str(LAST_YEAR)),
355
+ ("unknown_reporter", 2.0, 1.0, "reporter abbreviation not recognized"),
356
+ ]
357
+ BIAS = -4.0
citenet/text.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tokenizer side: normalize a citation string and turn it into char codes.
2
+
3
+ This is the only code that runs outside the network, the same role a BPE
4
+ tokenizer plays for an LLM. Everything after the char codes happens inside
5
+ the Core ML model.
6
+ """
7
+ import re
8
+ import unicodedata
9
+
10
+ import numpy as np
11
+
12
+ from . import spec
13
+
14
+ _JOIN = re.compile(r"\.\s+(?=(?!at\b)[a-z]|\d+(?:d|th|st|nd|rd)\b)")
15
+ # parentheticals that carry other citations, not this one's court and year: cut them off
16
+ EXPL = ("quoting", "citing", "internal", "alteration", "emphasis", "quotation", "affirmed", "aff'd",
17
+ "rev'd", "cert", "vacated", "overruled", "abrogated", "superseded", "modified")
18
+ _EXPL = re.compile(r"\s*\((?:" + "|".join(re.escape(w) for w in EXPL) + r"|\d+ )")
19
+ _TRAIL = re.compile(r"(?<=[\d)])[\s.,;:]+$|[\s,;:]+$")
20
+
21
+
22
+ def normalize(cite: str) -> str:
23
+ s = unicodedata.normalize("NFKC", cite)
24
+ s = s.replace("’", "'").replace("‘", "'").replace("`", "'")
25
+ s = s.replace("–", "-").replace("—", "-").replace("§", "s")
26
+ s = re.sub(r"\s+", " ", s).strip().lower()
27
+ m = _EXPL.search(s)
28
+ if m:
29
+ s = s[:m.start()]
30
+ s = _TRAIL.sub("", s)
31
+ s = _JOIN.sub(".", s) # "F. Supp. 2d" -> "f.supp.2d", "S. Ct." -> "s.ct."
32
+ if len(s) > spec.MAXCHARS: # keep the head (volume/reporter) and the tail (court, year)
33
+ s = s[:24] + s[-(spec.MAXCHARS - 24):]
34
+ return s
35
+
36
+
37
+ def encode(cites, seq=spec.SEQ) -> np.ndarray:
38
+ """List of raw citation strings -> float32 codes [N, 1, 1, seq]."""
39
+ out = np.full((len(cites), 1, 1, seq), spec.PAD, dtype=np.float32)
40
+ for i, c in enumerate(cites):
41
+ s = normalize(c)
42
+ codes = [spec.BOS] + [spec.code(ch) for ch in s] + [spec.EOS]
43
+ out[i, 0, 0, :len(codes)] = codes
44
+ return out
45
+
46
+
47
+ # ---------------------------------------------------------------- extraction
48
+ _TOKEN = r"(?:[A-Z][A-Za-z'’&]*\.?|\d+(?:d|th|st|nd|rd)\b)"
49
+ CITE_RE = re.compile(
50
+ r"\b(?P<vol>\d{1,4})\s+"
51
+ r"(?P<rep>" + _TOKEN + r"(?:\s?" + _TOKEN + r"){0,4})"
52
+ r"(?:\s*,\s*at\s+|\s+(?:at\s+)?)(?P<page>\*?\d{1,8})" # "976 F. 2d, at 540" too
53
+ r"(?P<pin>(?:\s*,\s*(?:at\s+)?\*?\d+(?:\s*[-–]\s*\*?\d+)?)*)"
54
+ r"(?P<paren>\s*\((?:[^()]{0,60}?)\d{4}\))?"
55
+ )
56
+
57
+
58
+ # words that open a match but never a reporter: "No. 12 Section 3", "325 East 42nd", "5 July 2010"
59
+ STOP = set("""No. Nos. No Id. Id Ibid. Section Sec. In On At Page Pages Rule Rules Cir. Supp. Tr. Year Years
60
+ East West North South And Or The Of To CA Art. Article Para. Count Counts Exhibit Ex. Doc. Dkt. ECF
61
+ January February March April May June July August September October November December
62
+ Jan. Feb. Mar. Apr. Jun. Jul. Aug. Sep. Sept. Oct. Nov. Dec.
63
+ C Civ Civ. Cr Cr. Crim. Crim CV CR""".split()) # docket numbers: "70 Civ 1113", "69 C 173"
64
+ _FIRST = re.compile(_TOKEN)
65
+
66
+
67
+ def extract(text: str):
68
+ """Find reporter-style citations in free text. Returns [(start, end, cite)]."""
69
+ out = []
70
+ for m in CITE_RE.finditer(text):
71
+ rep = m.group("rep")
72
+ if not any(ch.isupper() for ch in rep):
73
+ continue
74
+ if _FIRST.match(rep).group(0) in STOP:
75
+ continue
76
+ out.append((m.start(), m.end(), m.group(0)))
77
+ return out
demo/index.html ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CiteNet</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f7f6f2; --panel: #ffffff; --ink: #1c1b19; --muted: #77746b; --line: #e6e3da;
10
+ --ok: #1f7a4d; --ok-bg: #e2f2e8; --bad: #c2321f; --bad-bg: #fbe3de; --warn: #a86a00; --warn-bg: #fbeed0;
11
+ --accent: #2f4fd6; --gpu: #1f7a4d;
12
+ --mono: ui-monospace, "SF Mono", Menlo, monospace;
13
+ --serif: "Iowan Old Style", Palatino, Georgia, serif;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --bg: #141412; --panel: #1c1b19; --ink: #ecebe6; --muted: #918e85; --line: #2d2c28;
18
+ --ok: #4fc487; --ok-bg: #1c3326; --bad: #ff7a66; --bad-bg: #42201a; --warn: #f0b545; --warn-bg: #3b2f17;
19
+ --accent: #8ea2ff; --gpu: #4fc487;
20
+ }
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
24
+ .wrap { max-width: 860px; margin: 0 auto; padding: 36px 20px 48px; }
25
+ h1 { margin: 0; font-size: 30px; letter-spacing: -0.02em; }
26
+ .lede { color: var(--muted); margin: 4px 0 22px; }
27
+ .lede a { color: var(--ink); }
28
+ .samples { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; color: var(--muted); font-size: 13px; flex-wrap: wrap; }
29
+ .samples button { font: inherit; font-size: 13px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); padding: 4px 10px; border-radius: 999px; cursor: pointer; }
30
+ .samples button.on { border-color: var(--ink); }
31
+ .editor { position: relative; height: 340px; min-height: 200px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); }
32
+ .editor .backdrop, .editor textarea {
33
+ position: absolute; inset: 0; margin: 0; padding: 16px 18px; font: 17px/1.65 var(--serif);
34
+ white-space: pre-wrap; overflow-wrap: break-word; overflow-y: auto; border: 0;
35
+ }
36
+ .editor .backdrop { color: transparent; pointer-events: none; }
37
+ .editor textarea { background: transparent; color: var(--ink); resize: none; outline: none; caret-color: var(--accent); }
38
+ mark { color: transparent; border-radius: 3px; }
39
+ mark.ok { background: var(--ok-bg); }
40
+ mark.phantom { background: var(--bad-bg); box-shadow: inset 0 -2px 0 var(--bad); }
41
+ mark.unknown { background: var(--warn-bg); }
42
+ mark.hot { outline: 2px solid var(--bad); }
43
+ .status { margin: 12px 2px 6px; font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
44
+ .status b { color: var(--ink); font-weight: 600; }
45
+ .hits { list-style: none; margin: 0; padding: 0; }
46
+ .hits li { display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 2px 12px; padding: 9px 2px; border-top: 1px solid var(--line); }
47
+ .hits li:first-child { border-top: 0; }
48
+ .hits .d { width: 10px; height: 10px; border-radius: 50%; margin-top: 6px; background: var(--bad); }
49
+ .hits .d.unknown { background: var(--warn); }
50
+ .hits .c { font: 13.5px var(--mono); overflow-wrap: anywhere; }
51
+ .hits .w { grid-column: 2; color: var(--muted); font-size: 14px; }
52
+ .none { color: var(--muted); font-size: 14px; padding: 6px 2px; }
53
+ .speed { margin-top: 34px; padding-top: 24px; border-top: 1px solid var(--line); display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
54
+ .speed button { font: 600 15px -apple-system, system-ui, sans-serif; border: 0; background: var(--ink); color: var(--bg); padding: 11px 18px; border-radius: 10px; cursor: pointer; }
55
+ .speed .out { flex: 1; min-width: 240px; }
56
+ .big { font-size: 34px; font-weight: 700; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1.1; }
57
+ .small { color: var(--muted); font-size: 13.5px; }
58
+ .bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; margin-top: 8px; background: var(--line); }
59
+ .bar .ane { background: var(--accent); }
60
+ .bar .gpu { background: var(--gpu); }
61
+ .foot { margin-top: 34px; color: var(--muted); font-size: 13px; }
62
+ .foot a { color: inherit; }
63
+ @media (max-width: 600px) { .wrap { padding: 24px 16px 40px; } .big { font-size: 28px; } }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <div class="wrap">
68
+ <h1>CiteNet</h1>
69
+ <div class="lede">Flags legal citations that cannot exist. <span id="params">256k</span> weights written by hand, no training, running on Core ML.
70
+ <a href="/live">Live: scan 3.3 GB of real case law →</a> <a href="/wall">Watch it read →</a></div>
71
+
72
+ <div class="samples">Try:
73
+ <button data-sample="brief">Planted fakes</button>
74
+ <button data-sample="lepc">Real brief (LePhantomCite)</button>
75
+ <button data-sample="mata">Mata v. Avianca</button>
76
+ </div>
77
+ <div class="editor">
78
+ <div class="backdrop" id="backdrop"></div>
79
+ <textarea id="ta" spellcheck="false" placeholder="Paste a brief…"></textarea>
80
+ </div>
81
+
82
+ <div class="status" id="status">&nbsp;</div>
83
+ <ul class="hits" id="hits"></ul>
84
+
85
+ <div class="speed">
86
+ <button id="fire">Check 1,000,000 citations</button>
87
+ <div class="out">
88
+ <div class="big" id="fire-n">–</div>
89
+ <div class="small" id="fire-s">Neural Engine + GPU, native Core ML</div>
90
+ <div class="bar" id="bar"></div>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="foot">LePhantomCite eval: 31/31 phantom citations caught, 1 of 983 real ones flagged.
95
+ Green means the citation is possible, not that the case exists; the Mata v. Avianca fakes copy real formats and pass.
96
+ <a href="/inspect">Inspect the neurons</a></div>
97
+ </div>
98
+
99
+ <script>
100
+ const $ = (id) => document.getElementById(id);
101
+ const ta = $("ta"), bd = $("backdrop");
102
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
103
+ let items = [], inflight = false, dirty = false, hot = -1;
104
+
105
+ function paint() {
106
+ const t = ta.value; let out = "", pos = 0;
107
+ items.forEach((it, i) => {
108
+ out += esc(t.slice(pos, it.start)) +
109
+ `<mark class="${it.status}${i === hot ? " hot" : ""}">${esc(t.slice(it.start, it.end))}</mark>`;
110
+ pos = it.end;
111
+ });
112
+ bd.innerHTML = out + esc(t.slice(pos)) + " \n";
113
+ // grow the editor to fit the text (the backdrop holds the same text, so its height is the content height)
114
+ const ed = ta.parentElement;
115
+ ed.style.height = "auto";
116
+ bd.style.position = "static";
117
+ const h = Math.min(Math.max(bd.scrollHeight + 2, 200), 620);
118
+ bd.style.position = "";
119
+ ed.style.height = h + "px";
120
+ bd.scrollTop = ta.scrollTop;
121
+ }
122
+
123
+ function render(r) {
124
+ const bad = items.filter(i => i.status === "phantom").length;
125
+ const unit = r.model.includes("ANE") ? "the Neural Engine" : "the CPU";
126
+ const ms = r.model_ms < 1 ? r.model_ms.toFixed(2) : r.model_ms.toFixed(1);
127
+ $("status").innerHTML = r.n
128
+ ? `<b>${r.n}</b> citations · <b style="color:var(--bad)">${bad}</b> phantom · checked in <b>${ms} ms</b> on ${unit}`
129
+ : "No citations found.";
130
+ const flagged = items.map((it, i) => [it, i]).filter(([it]) => it.status !== "ok");
131
+ $("hits").innerHTML = flagged.length
132
+ ? flagged.map(([it, i]) => `<li data-i="${i}"><span class="d ${it.status}"></span><span class="c">${esc(it.cite)}</span>` +
133
+ `<span class="w">${esc(it.why.join("; "))}</span></li>`).join("")
134
+ : (r.n ? `<div class="none">Every citation is structurally possible.</div>` : "");
135
+ $("hits").querySelectorAll("li").forEach(li => {
136
+ li.onmouseenter = () => { hot = +li.dataset.i; paint(); };
137
+ li.onmouseleave = () => { hot = -1; paint(); };
138
+ });
139
+ }
140
+
141
+ async function check() {
142
+ if (inflight) { dirty = true; return; }
143
+ inflight = true; dirty = false;
144
+ const r = await fetch("/check", { method: "POST", body: JSON.stringify({ text: ta.value }) }).then(r => r.json());
145
+ items = r.items; paint(); render(r);
146
+ inflight = false;
147
+ if (dirty) check();
148
+ }
149
+
150
+ async function loadSample(kind) {
151
+ document.querySelectorAll("[data-sample]").forEach(b => b.classList.toggle("on", b.dataset.sample === kind));
152
+ const r = await fetch("/sample?kind=" + kind).then(r => r.json());
153
+ ta.value = r.text; ta.scrollTop = 0;
154
+ await check();
155
+ }
156
+
157
+ document.querySelectorAll("[data-sample]").forEach(b => b.onclick = () => loadSample(b.dataset.sample));
158
+ ta.addEventListener("input", check);
159
+ ta.addEventListener("scroll", () => { bd.scrollTop = ta.scrollTop; });
160
+
161
+ $("fire").onclick = async () => {
162
+ $("fire-n").textContent = "…"; $("bar").innerHTML = "";
163
+ const r = await fetch("/firehose?n=1000000&lanes=ane%2Bgpu").then(r => r.json());
164
+ const t0 = performance.now();
165
+ const tick = (t) => {
166
+ const f = Math.min(1, (t - t0) / 600);
167
+ $("fire-n").textContent = Math.round(r.n * f).toLocaleString();
168
+ if (f < 1) return requestAnimationFrame(tick);
169
+ $("fire-n").textContent = `${r.n.toLocaleString()} in ${Math.round(r.seconds * 1000)} ms`;
170
+ };
171
+ requestAnimationFrame(tick);
172
+ const ane = r.lanes.ane?.cites || 0, gpu = r.lanes.gpu?.cites || 0;
173
+ $("fire-s").textContent = `${(r.per_sec / 1e6).toFixed(2)}M citations/s · Neural Engine ${Math.round(100 * ane / r.n)}% + GPU ${Math.round(100 * gpu / r.n)}%`;
174
+ $("bar").innerHTML = `<div class="ane" style="width:${100 * ane / r.n}%"></div><div class="gpu" style="width:${100 * gpu / r.n}%"></div>`;
175
+ };
176
+
177
+ fetch("/info").then(r => r.json()).then(i => { $("params").textContent = Math.round(i.params / 1000) + "k"; });
178
+ const q = new URLSearchParams(location.search);
179
+ // one silent request first: the Neural Engine takes ~10 ms to wake after idle, and the
180
+ // first number on screen should be the steady-state one
181
+ fetch("/check", { method: "POST", body: JSON.stringify({ text: "347 U.S. 483 (1954). ".repeat(12) }) })
182
+ .then(() => loadSample(q.get("sample") || "brief"))
183
+ .then(() => { if (q.get("fire")) $("fire").click(); });
184
+ </script>
185
+ </body>
186
+ </html>
demo/inspect.html ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CiteNet</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f6f5f1; --panel: #ffffff; --ink: #1c1b19; --muted: #6b6860; --line: #e4e1d8;
10
+ --ok: #1f7a4d; --ok-bg: #dff1e6; --bad: #c2321f; --bad-bg: #fbe1dc; --warn: #a86a00; --warn-bg: #fbedcc;
11
+ --accent: #2f4fd6; --accent-bg: #e6eafc; --cell: #2f4fd6; --grid: #eeece6;
12
+ --mono: ui-monospace, "SF Mono", Menlo, monospace;
13
+ --text: "Iowan Old Style", "Palatino", Georgia, serif;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --bg: #141412; --panel: #1d1c1a; --ink: #ecebe6; --muted: #9a978e; --line: #2f2e2a;
18
+ --ok: #4fc487; --ok-bg: #1d3528; --bad: #ff7a66; --bad-bg: #44211b; --warn: #f0b545; --warn-bg: #3d3018;
19
+ --accent: #8ea2ff; --accent-bg: #252c4d; --cell: #8ea2ff; --grid: #2a2926;
20
+ }
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.45 -apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif; }
24
+ header { padding: 22px 28px 8px; display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; }
25
+ h1 { margin: 0; font-size: 26px; letter-spacing: -0.02em; }
26
+ header .tag { color: var(--muted); }
27
+ header .tag b { color: var(--ink); font-weight: 600; }
28
+ .stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; padding: 10px 28px 16px; }
29
+ .stat { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
30
+ .stat .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
31
+ .stat .v { font-size: 28px; font-weight: 650; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
32
+ .stat .s { color: var(--muted); font-size: 13px; }
33
+ main { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 16px; padding: 0 28px 20px; }
34
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; min-width: 0; }
35
+ .panel h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin: 0 0 10px; font-weight: 600; }
36
+ .toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; align-items: center; }
37
+ button { font: inherit; font-size: 13px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); padding: 6px 11px; border-radius: 8px; cursor: pointer; }
38
+ button:hover { border-color: var(--accent); }
39
+ button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
40
+ .editor { position: relative; height: 470px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
41
+ .editor .backdrop, .editor textarea {
42
+ position: absolute; inset: 0; margin: 0; padding: 14px 16px; font: 17px/1.6 var(--text); white-space: pre-wrap; overflow-wrap: break-word;
43
+ overflow-y: auto; border: 0; letter-spacing: 0;
44
+ }
45
+ .editor .backdrop { color: transparent; pointer-events: none; }
46
+ .editor textarea { background: transparent; color: var(--ink); resize: none; outline: none; caret-color: var(--accent); }
47
+ mark { color: transparent; border-radius: 3px; padding: 1px 0; }
48
+ mark.ok { background: var(--ok-bg); box-shadow: inset 0 -2px 0 var(--ok); }
49
+ mark.phantom { background: var(--bad-bg); box-shadow: inset 0 -2px 0 var(--bad); }
50
+ mark.unknown { background: var(--warn-bg); box-shadow: inset 0 -2px 0 var(--warn); }
51
+ mark.sel { outline: 2px solid var(--accent); }
52
+ .legend { display: flex; gap: 14px; color: var(--muted); font-size: 13px; margin-top: 8px; flex-wrap: wrap; }
53
+ .dot { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
54
+ .list { display: flex; flex-direction: column; gap: 4px; max-height: 210px; overflow-y: auto; margin-bottom: 12px; }
55
+ .row { display: grid; grid-template-columns: 74px minmax(0, 1fr) 54px; gap: 8px; align-items: center; padding: 5px 8px; border-radius: 7px; cursor: pointer; font-size: 14px; }
56
+ .row:hover, .row.sel { background: var(--accent-bg); }
57
+ .row .c { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
58
+ .row .p { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12.5px; }
59
+ .badge { font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 6px; text-align: center; }
60
+ .badge.ok { color: var(--ok); background: var(--ok-bg); }
61
+ .badge.phantom { color: var(--bad); background: var(--bad-bg); }
62
+ .badge.unknown { color: var(--warn); background: var(--warn-bg); }
63
+ .insp h3 { font: 600 15px var(--mono); margin: 2px 0 8px; overflow-wrap: anywhere; }
64
+ .ev { display: grid; grid-template-columns: minmax(0, 1fr) 58px; gap: 4px 10px; font-size: 13.5px; margin: 6px 0 10px; }
65
+ .ev .n { color: var(--ink); }
66
+ .ev .n small { color: var(--muted); display: block; font-size: 12px; }
67
+ .ev .l { text-align: right; font: 600 13px var(--mono); color: var(--bad); }
68
+ .chips { display: flex; flex-wrap: wrap; gap: 5px; margin: 4px 0 10px; }
69
+ .chip { font: 12px var(--mono); padding: 2px 7px; border-radius: 6px; background: var(--accent-bg); color: var(--accent); }
70
+ .sub { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin: 12px 0 4px; font-weight: 600; }
71
+ svg text { fill: var(--muted); font: 11px -apple-system, system-ui, sans-serif; }
72
+ .heat { display: grid; gap: 1px; font: 11px var(--mono); margin-top: 4px; }
73
+ .heat .lab { white-space: nowrap; overflow: hidden; }
74
+ .heat div { height: 17px; display: flex; align-items: center; justify-content: center; }
75
+ .heat .lab { color: var(--muted); justify-content: flex-end; padding-right: 4px; }
76
+ .heat .cell { background: var(--grid); }
77
+ .heat .on { background: var(--cell); color: #fff; }
78
+ .heat .in { color: var(--ink); font-weight: 600; }
79
+ .filters { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
80
+ .filters button { font-family: var(--mono); font-size: 12px; padding: 3px 8px; }
81
+ .filters button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
82
+ .speed { padding: 0 28px 28px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
83
+ .split { display: flex; height: 22px; border-radius: 6px; overflow: hidden; margin-top: 10px; font: 600 12px var(--mono); }
84
+ .split div { display: flex; align-items: center; justify-content: center; color: #fff; white-space: nowrap; overflow: hidden; }
85
+ .split .ane { background: var(--accent); }
86
+ .split .gpu { background: var(--ok); }
87
+ .big { font-size: 40px; font-weight: 700; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
88
+ pre { font: 12.5px/1.5 var(--mono); margin: 6px 0 0; white-space: pre-wrap; color: var(--ink); }
89
+ .muted { color: var(--muted); }
90
+ .note { color: var(--muted); font-size: 13px; margin-top: 8px; }
91
+ .empty { color: var(--muted); padding: 20px 4px; }
92
+ .arch { font: 12.5px/1.7 var(--mono); color: var(--muted); }
93
+ .arch b { color: var(--ink); font-weight: 600; }
94
+ @media (max-width: 980px) {
95
+ main, .speed { grid-template-columns: minmax(0, 1fr); padding-left: 16px; padding-right: 16px; }
96
+ .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 10px 16px; }
97
+ header { padding: 18px 16px 6px; }
98
+ .editor { height: 380px; }
99
+ }
100
+ </style>
101
+ </head>
102
+ <body>
103
+ <header>
104
+ <h1>CiteNet</h1>
105
+ <div class="tag">A phantom legal-citation detector that was never trained. <b id="params">–</b> parameters, every nonzero weight written by hand, running on Core ML.</div>
106
+ </header>
107
+
108
+ <section class="stats">
109
+ <div class="stat"><div class="k">This check</div><div class="v" id="s-ms">–</div><div class="s" id="s-ms2">–</div></div>
110
+ <div class="stat"><div class="k">One citation</div><div class="v" id="h-one">–</div><div class="s">p50, b1 on CPU, native Swift</div></div>
111
+ <div class="stat"><div class="k">Bulk</div><div class="v" id="h-bulk">–</div><div class="s" id="h-bulk2">–</div></div>
112
+ <div class="stat"><div class="k">LePhantomCite eval</div><div class="v">31 / 31</div><div class="s">phantoms caught · 1 / 983 real cites flagged</div></div>
113
+ </section>
114
+
115
+ <main>
116
+ <section class="panel">
117
+ <h2>Brief</h2>
118
+ <div class="toolbar">
119
+ <button data-sample="brief">Brief with planted phantoms</button>
120
+ <button data-sample="lepc">Random LePhantomCite excerpt</button>
121
+ <button data-sample="mata">Mata v. Avianca fakes</button>
122
+ <span class="muted" id="src" style="font-size:12px"></span>
123
+ </div>
124
+ <div class="editor">
125
+ <div class="backdrop" id="backdrop"></div>
126
+ <textarea id="ta" spellcheck="false"></textarea>
127
+ </div>
128
+ <div class="legend">
129
+ <span><span class="dot" style="background:var(--bad)"></span>phantom: the citation is impossible</span>
130
+ <span><span class="dot" style="background:var(--warn)"></span>unrecognized reporter</span>
131
+ <span><span class="dot" style="background:var(--ok)"></span>structurally possible (not proof the case exists)</span>
132
+ </div>
133
+ </section>
134
+
135
+ <section class="panel insp">
136
+ <h2>Citations <span id="count" class="muted"></span></h2>
137
+ <div class="list" id="list"></div>
138
+ <div id="detail"><div class="empty">Click a citation to see which hand-written neurons fired.</div></div>
139
+ </section>
140
+ </main>
141
+
142
+ <section class="speed">
143
+ <div class="panel">
144
+ <h2>Speed</h2>
145
+ <div class="toolbar">
146
+ <span class="muted" style="font-size:13px">Check 1,000,000 citations on</span>
147
+ <button data-lanes="ane">Neural Engine</button>
148
+ <button data-lanes="gpu">GPU</button>
149
+ <button class="primary" data-lanes="ane+gpu">Neural Engine + GPU</button>
150
+ <button id="bench">Score the LePhantomCite eval split</button>
151
+ </div>
152
+ <div class="big" id="fire-n">–</div>
153
+ <div class="muted" id="fire-s">Native Swift Core ML (bench/firehose): ANE b512 and GPU b8192, two threads per unit.</div>
154
+ <div class="split" id="split"></div>
155
+ <div id="bench-out" class="note"></div>
156
+ <div class="sub">Native Swift benchmark, M5 Pro (bench/bench.swift)</div>
157
+ <pre id="swift"></pre>
158
+ </div>
159
+ <div class="panel">
160
+ <h2>How the weights were written</h2>
161
+ <div class="arch" id="arch"></div>
162
+ <p class="note">Nothing was trained. <code>citenet/spec.py</code> lists reporters, series, courts, West regions and approximate volume-to-year timelines; <code>citenet/net.py</code> compiles each entry into specific filters and ReLU neurons. Design used only the LePhantomCite <code>aux_train</code> split. The <code>eval</code> split was scored once with the frozen v1.0 weights: 31/31 phantoms, 3/983 false flags. Two of those were tokenizer bugs, fixed in v1.1 (shown here). The remaining flag, <code>652 A.2d 634 (D.C. Cir. 1995)</code>, pairs a state reporter with a federal circuit.</p>
163
+ <p class="note">Limits: a string-only model checks that a citation <i>could</i> exist. LePhantomCite's non-existent citations are structurally impossible (F.5th, S.W.4th), so it catches all of them. Real LLM fakes such as the Mata v. Avianca cases copy valid formats, so they pass. Catching those needs a case-law lookup.</p>
164
+ </div>
165
+ </section>
166
+
167
+ <script>
168
+ const $ = (id) => document.getElementById(id);
169
+ const ta = $("ta"), bd = $("backdrop");
170
+ let items = [], selected = -1, inflight = false, dirty = false, info = null;
171
+
172
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
173
+ const EVTEXT = {};
174
+
175
+ async function getJSON(u) { const r = await fetch(u); return r.json(); }
176
+
177
+ function paint() {
178
+ const t = ta.value; let out = "", pos = 0;
179
+ items.forEach((it, i) => {
180
+ out += esc(t.slice(pos, it.start));
181
+ out += `<mark class="${it.status}${i === selected ? " sel" : ""}">${esc(t.slice(it.start, it.end))}</mark>`;
182
+ pos = it.end;
183
+ });
184
+ out += esc(t.slice(pos)) + " \n";
185
+ bd.innerHTML = out;
186
+ bd.scrollTop = ta.scrollTop;
187
+ }
188
+
189
+ function renderList() {
190
+ $("count").textContent = items.length ? `· ${items.length} found · ${items.filter(i => i.status === "phantom").length} phantom` : "";
191
+ $("list").innerHTML = items.map((it, i) =>
192
+ `<div class="row${i === selected ? " sel" : ""}" data-i="${i}">
193
+ <span class="badge ${it.status}">${it.status === "ok" ? "possible" : it.status}</span>
194
+ <span class="c">${esc(it.cite)}</span><span class="p">${it.p.toFixed(3)}</span></div>`).join("");
195
+ $("list").querySelectorAll(".row").forEach(r => r.onclick = () => select(+r.dataset.i));
196
+ }
197
+
198
+ async function check() {
199
+ if (inflight) { dirty = true; return; }
200
+ inflight = true; dirty = false;
201
+ const t0 = performance.now();
202
+ const r = await fetch("/check", { method: "POST", body: JSON.stringify({ text: ta.value }) }).then(r => r.json());
203
+ const rtt = performance.now() - t0;
204
+ const prevCite = items[selected]?.cite;
205
+ items = r.items;
206
+ selected = prevCite ? items.findIndex(i => i.cite === prevCite) : selected;
207
+ if (selected >= items.length) selected = -1;
208
+ $("s-ms").textContent = r.n ? `${r.model_ms < 1 ? r.model_ms.toFixed(2) : r.model_ms.toFixed(1)} ms` : "–";
209
+ $("s-ms2").textContent = r.n ? `${r.n} citations on ${r.model} · ${rtt.toFixed(1)} ms round trip` : "no citations";
210
+ paint(); renderList();
211
+ if (selected >= 0) renderDetail(items[selected]); else if (!items.length) $("detail").innerHTML = `<div class="empty">No reporter citations found.</div>`;
212
+ inflight = false;
213
+ if (dirty) check();
214
+ }
215
+
216
+ function select(i) {
217
+ selected = i; paint(); renderList(); renderDetail(items[i]);
218
+ }
219
+
220
+ const NICE = (n) => n
221
+ .replace(/^class:/, "class ").replace(/^region:/, "region ").replace(/^home:/, "home state ")
222
+ .replace(/^state:/, "court state ").replace(/^court:/, "court ").replace(/^phantom:/, "phantom series ");
223
+
224
+ async function renderDetail(it) {
225
+ if (!it) return;
226
+ const logit = it.logit_bias + it.reasons.reduce((a, r) => a + r.logit, 0);
227
+ let h = `<div style="display:flex;gap:10px;align-items:center"><span class="badge ${it.status}">${it.status === "ok" ? "possible" : it.status}</span>
228
+ <span class="muted" style="font-size:13px">p(phantom) = ${it.p.toFixed(3)} · logit ${logit.toFixed(2)}</span></div>
229
+ <h3>${esc(it.norm)}</h3>`;
230
+ h += `<div class="sub">Evidence → logit (bias ${it.logit_bias})</div>`;
231
+ h += it.reasons.length ? `<div class="ev">${it.reasons.map(r =>
232
+ `<div class="n">${r.name.replace(/_/g, " ")}${r.name === "volume_year" ? ` (${r.value.toFixed(1)} yr past tolerance)` : ""}<small>${esc(r.text)}</small></div><div class="l">+${r.logit.toFixed(2)}</div>`).join("")}</div>`
233
+ : `<div class="note" style="margin:4px 0 10px">No rule fired. Volume, reporter, court and year are mutually consistent.</div>`;
234
+ const parts = [];
235
+ if (it.volume) parts.push(`volume = ${it.volume}`);
236
+ if (it.year) parts.push(`year = ${it.year}`);
237
+ if (it.knots) parts.push(`${it.knots} volume knots active`);
238
+ h += `<div class="sub">Neurons that fired</div><div class="chips">${[...parts, ...it.fired.map(NICE)].map(c => `<span class="chip">${esc(c)}</span>`).join("")}</div>`;
239
+ h += `<div id="chart"></div><div class="sub">First-layer filters that matched</div><div class="filters" id="filters"></div><div id="heat"></div>`;
240
+ $("detail").innerHTML = h;
241
+ if (it.curve) drawCurve(it);
242
+ const fired = await getJSON("/fired?cite=" + encodeURIComponent(it.cite));
243
+ $("filters").innerHTML = fired.map((f, i) => `<button data-i="${i}">${esc(f.name)}</button>`).join("") || `<span class="muted">none</span>`;
244
+ const btns = $("filters").querySelectorAll("button");
245
+ btns.forEach(b => b.onclick = () => { btns.forEach(x => x.classList.remove("on")); b.classList.add("on"); drawFilter(fired[+b.dataset.i], it); });
246
+ const first = fired.findIndex(f => f.name.startsWith("rep:") || f.name.startsWith("series:"));
247
+ if (btns.length) btns[Math.max(0, first)].click();
248
+ }
249
+
250
+ async function drawCurve(it) {
251
+ const c = await getJSON("/curve?key=" + it.curve);
252
+ const W = 460, H = 170, L = 40, R = 10, T = 10, B = 24;
253
+ const xs = c.net.map(p => p[0]), ys = c.net.map(p => p[1]);
254
+ const y0 = Math.min(...ys, it.year || 1e9) - c.tol - 3, y1 = Math.max(...ys, it.year || 0) + c.tol + 3;
255
+ const x1 = Math.max(c.vmax, it.volume || 0) * 1.04;
256
+ const X = v => L + (W - L - R) * v / x1, Y = y => T + (H - T - B) * (1 - (y - y0) / (y1 - y0));
257
+ const up = c.net.map(p => `${X(p[0])},${Y(p[1] + c.tol)}`), dn = c.net.slice().reverse().map(p => `${X(p[0])},${Y(p[1] - c.tol)}`);
258
+ const line = c.net.map(p => `${X(p[0])},${Y(p[1])}`).join(" ");
259
+ const ticks = [];
260
+ const step = (y1 - y0) > 80 ? 40 : (y1 - y0) > 30 ? 10 : 5;
261
+ for (let y = Math.ceil(y0 / step) * step; y <= y1; y += step) ticks.push(`<line x1="${L}" x2="${W - R}" y1="${Y(y)}" y2="${Y(y)}" stroke="var(--grid)"/><text x="${L - 5}" y="${Y(y) + 4}" text-anchor="end">${y}</text>`);
262
+ let pt = "";
263
+ if (it.year && it.volume) {
264
+ const out = it.reasons.some(r => r.name === "volume_year" || r.name === "volume_missing");
265
+ pt = `<circle cx="${X(it.volume)}" cy="${Y(it.year)}" r="6" fill="${out ? "var(--bad)" : "var(--ok)"}" stroke="var(--panel)" stroke-width="2"/>`;
266
+ }
267
+ $("chart").innerHTML = `<div class="sub">Volume → year: what the knot neurons compute for ${esc(c.reporters.join(" / "))} (±${c.tol} yr)</div>
268
+ <svg viewBox="0 0 ${W} ${H}" width="100%" role="img" aria-label="volume to year curve">
269
+ ${ticks.join("")}
270
+ <polygon points="${up.concat(dn).join(" ")}" fill="var(--accent-bg)"/>
271
+ <polyline points="${line}" fill="none" stroke="var(--accent)" stroke-width="2"/>
272
+ <line x1="${X(c.vmax)}" x2="${X(c.vmax)}" y1="${T}" y2="${H - B}" stroke="var(--muted)" stroke-dasharray="3 3"/>
273
+ <text x="${X(c.vmax) - 4}" y="${T + 10}" text-anchor="end">last volume ${c.vmax}</text>
274
+ <text x="${L}" y="${H - 6}">vol 0</text><text x="${W - R}" y="${H - 6}" text-anchor="end">vol ${Math.round(x1)}</text>
275
+ ${pt}
276
+ </svg>`;
277
+ }
278
+
279
+ async function drawFilter(f, it) {
280
+ const w = await getJSON("/filter?name=" + encodeURIComponent(f.name));
281
+ const norm = "‹" + it.norm + "›"; // BOS / EOS markers, as the network sees them
282
+ const win = [];
283
+ for (let o = 0; o < w.k; o++) {
284
+ const ch = norm[f.pos + 1 + o];
285
+ win.push(ch === undefined ? "" : ch === " " ? "␣" : ch);
286
+ }
287
+ let g = `<div class="heat" style="grid-template-columns: 64px repeat(${w.k}, minmax(0, 1fr))">`;
288
+ g += `<div class="lab">input</div>` + win.map(c => `<div class="in">${esc(c)}</div>`).join("");
289
+ for (const r of w.rows) {
290
+ g += `<div class="lab">${esc(r.ch)}</div>` + r.w.map(v => `<div class="cell${v ? " on" : ""}"></div>`).join("");
291
+ }
292
+ g += `</div><div class="note">${esc(w.name)} · ${w.layer} · ${w.nonzero} nonzero of ${w.params} weights · bias ${w.bias}. ` +
293
+ `It outputs relu(matches + bias), which is 1 only when every column hits.</div>`;
294
+ $("heat").innerHTML = g;
295
+ }
296
+
297
+ async function loadSample(kind, pick) {
298
+ const r = await getJSON("/sample?kind=" + kind);
299
+ ta.value = r.text; selected = -1;
300
+ $("src").textContent = kind === "lepc" ? `LePhantomCite eval · ${r.file} · planted: ${Object.values(r.planted).join(", ") || "none"}` : "";
301
+ await check();
302
+ const want = pick ? items.findIndex(i => i.cite.includes(pick)) : -1;
303
+ const firstBad = items.findIndex(i => i.status === "phantom");
304
+ if (want >= 0) select(want); else if (firstBad >= 0) select(firstBad); else if (items.length) select(0);
305
+ }
306
+
307
+ document.querySelectorAll("[data-sample]").forEach(b => b.onclick = () => loadSample(b.dataset.sample));
308
+ ta.addEventListener("input", () => check());
309
+ ta.addEventListener("scroll", () => { bd.scrollTop = ta.scrollTop; });
310
+ ta.addEventListener("click", () => {
311
+ const p = ta.selectionStart, i = items.findIndex(it => p >= it.start && p <= it.end);
312
+ if (i >= 0) select(i);
313
+ });
314
+
315
+ document.querySelectorAll("[data-lanes]").forEach(b => b.onclick = async () => {
316
+ const lanes = b.dataset.lanes;
317
+ $("fire-n").textContent = "…"; $("split").innerHTML = "";
318
+ const r = await getJSON("/firehose?n=1000000&lanes=" + encodeURIComponent(lanes));
319
+ const dur = 700, t0 = performance.now();
320
+ const tick = (t) => {
321
+ const f = Math.min(1, (t - t0) / dur);
322
+ $("fire-n").textContent = Math.round(r.n * f).toLocaleString() + " citations";
323
+ if (f < 1) requestAnimationFrame(tick);
324
+ else $("fire-n").textContent = `${r.n.toLocaleString()} in ${(r.seconds * 1000).toFixed(0)} ms`;
325
+ };
326
+ requestAnimationFrame(tick);
327
+ const name = { ane: "Neural Engine", gpu: "GPU", "ane+gpu": "Neural Engine + GPU" }[lanes];
328
+ $("fire-s").textContent = `${r.per_sec.toLocaleString()} citations/s on the ${name} · ${r.flagged.toLocaleString()} flagged · native Swift Core ML`;
329
+ $("split").innerHTML = Object.entries(r.lanes).map(([k, v]) =>
330
+ `<div class="${k}" style="width:${100 * v.cites / r.n}%">${k.toUpperCase()} b${v.batch} · ${(v.cites / r.seconds / 1e3).toFixed(0)}k/s</div>`).join("");
331
+ });
332
+ $("bench").onclick = async () => {
333
+ const r = await getJSON("/bench");
334
+ $("bench-out").innerHTML = `${r.n.toLocaleString()} eval citations in <b>${r.ms} ms</b> on ${r.model}: ` +
335
+ `${r.recall[0]}/${r.recall[1]} phantoms caught, ${r.false_flags[0]}/${r.false_flags[1]} real citations flagged` +
336
+ (r.false_flag_cites.length ? ` (${r.false_flag_cites.map(esc).join("; ")})` : "") + ".";
337
+ };
338
+
339
+ (async () => {
340
+ info = await getJSON("/info");
341
+ $("params").textContent = `${info.params.toLocaleString()}`;
342
+ $("swift").textContent = info.swift.trim();
343
+ const hl = info.headline, k = (n) => n >= 1e6 ? (n / 1e6).toFixed(2) + "M" : Math.round(n / 1e3) + "k";
344
+ if (hl.b1_cpu) $("h-one").textContent = `${Math.round(hl.b1_cpu.p50_us)} µs`;
345
+ const both = hl["concurrent:ane b512 + gpu b8192"];
346
+ if (both) {
347
+ $("h-bulk").textContent = `${k(both)}/s`;
348
+ $("h-bulk2").textContent = `citations/s, Neural Engine + GPU together (ANE alone ${k(hl.b512_ane.per_sec)}/s, GPU ${k(hl.b8192_gpu.per_sec)}/s)`;
349
+ } else if (hl.b512_ane) {
350
+ $("h-bulk").textContent = `${k(hl.b512_ane.per_sec)}/s`; $("h-bulk2").textContent = "citations/s, b512 on the Neural Engine";
351
+ }
352
+ const L = info.layers;
353
+ $("arch").innerHTML = [
354
+ `<b>char codes</b> [B, 1, 1, 64]`,
355
+ `→ <b>one-hot</b> relu(1 − |code − k|), k = 0…47 (no weights)`,
356
+ `→ <b>${L.rep} reporter filters</b> (conv 1×15) + <b>${L.court} court filters</b> (conv 1×8) + <b>6 digit decoders</b>, max over positions`,
357
+ `→ <b>${L.concepts} concept neurons</b>: reporter classes, West regions, courts, a 29-knot volume thermometer`,
358
+ `→ <b>${L.rules} rule neurons</b>: AND(class, bad court), |year − curve(volume)| − tol, volume > last`,
359
+ `→ <b>${L.evidence} evidence</b> sums, saturated → <b>logit</b> → sigmoid`,
360
+ `${info.params.toLocaleString()} parameters · ${info.nonzero.toLocaleString()} nonzero · 0 training steps`,
361
+ ].join("<br>");
362
+ const q = new URLSearchParams(location.search); // ?sample=mata&pick=925 (deep links for screenshots)
363
+ await loadSample(q.get("sample") || "brief", q.get("pick"));
364
+ if (q.get("fire")) document.querySelector(`[data-lanes="${q.get("fire")}"]`)?.click();
365
+ })();
366
+ </script>
367
+ </body>
368
+ </html>
demo/live.html ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CiteNet Live</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f7f6f2; --panel: #ffffff; --ink: #1c1b19; --muted: #77746b; --line: #e6e3da;
10
+ --ok: #1f7a4d; --ok-bg: #e2f2e8; --bad: #c2321f; --bad-bg: #fbe3de;
11
+ --accent: #2f4fd6; --gpu: #1f7a4d;
12
+ --mono: ui-monospace, "SF Mono", Menlo, monospace;
13
+ --serif: "Iowan Old Style", Palatino, Georgia, serif;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --bg: #141412; --panel: #1c1b19; --ink: #ecebe6; --muted: #918e85; --line: #2d2c28;
18
+ --ok: #4fc487; --ok-bg: #1c3326; --bad: #ff7a66; --bad-bg: #42201a;
19
+ --accent: #8ea2ff; --gpu: #4fc487;
20
+ }
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
24
+ .wrap { max-width: 980px; margin: 0 auto; padding: 36px 20px 48px; }
25
+ h1 { margin: 0; font-size: 30px; letter-spacing: -0.02em; }
26
+ .lede { color: var(--muted); margin: 4px 0 20px; }
27
+ .lede a { color: inherit; }
28
+ .controls { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
29
+ .controls button { font: 600 15px -apple-system, system-ui, sans-serif; border: 0; background: var(--ink); color: var(--bg); padding: 11px 18px; border-radius: 10px; cursor: pointer; }
30
+ .controls label { color: var(--muted); font-size: 14px; display: flex; gap: 6px; align-items: center; cursor: pointer; }
31
+ .nums { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 26px 0 10px; }
32
+ .num .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .07em; }
33
+ .num .v { font-size: 38px; font-weight: 700; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1.15; }
34
+ .num .r { color: var(--muted); font-size: 14px; font-variant-numeric: tabular-nums; }
35
+ .num .r b { color: var(--ink); font-weight: 600; }
36
+ .bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; background: var(--line); }
37
+ .bar div { background: var(--ink); width: 0; transition: width .1s linear; }
38
+ .meta { color: var(--muted); font-size: 13px; margin-top: 8px; font-variant-numeric: tabular-nums; min-height: 1.5em; }
39
+ .cols { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 24px; margin-top: 26px; }
40
+ h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin: 0 0 8px; font-weight: 600; }
41
+ .tape { height: 420px; overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); padding: 8px 14px;
42
+ -webkit-mask-image: linear-gradient(to bottom, #000 70%, transparent); mask-image: linear-gradient(to bottom, #000 70%, transparent); }
43
+ .tape div { font: 14px/1.55 var(--serif); padding: 4px 0; border-bottom: 1px solid var(--line); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
44
+ .tape mark { background: var(--ok-bg); color: var(--ink); border-radius: 3px; padding: 0 2px; }
45
+ .tape mark.bad { background: var(--bad-bg); box-shadow: inset 0 -2px 0 var(--bad); }
46
+ .caught { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow: hidden; }
47
+ .caught li { padding: 8px 0; border-top: 1px solid var(--line); }
48
+ .caught li:first-child { border-top: 0; }
49
+ .caught .c { font: 13px var(--mono); color: var(--bad); overflow-wrap: anywhere; }
50
+ .caught .w { color: var(--muted); font-size: 13.5px; }
51
+ .note { color: var(--muted); font-size: 13px; margin-top: 10px; }
52
+ .empty { color: var(--muted); font-size: 14px; }
53
+ @media (max-width: 760px) {
54
+ .wrap { padding: 24px 16px 40px; }
55
+ .nums { grid-template-columns: minmax(0, 1fr); gap: 10px; }
56
+ .num .v { font-size: 30px; }
57
+ .cols { grid-template-columns: minmax(0, 1fr); }
58
+ .tape { height: 300px; }
59
+ }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <div class="wrap">
64
+ <h1>CiteNet Live</h1>
65
+ <div class="lede" id="lede">Real US court opinions, scanned for impossible citations as fast as the Mac can read them. <a href="/">Back</a></div>
66
+
67
+ <div class="controls">
68
+ <button id="go">Scan</button>
69
+ <label><input type="checkbox" id="loop"> keep going (loop the corpus)</label>
70
+ </div>
71
+
72
+ <div class="nums">
73
+ <div class="num"><div class="k">Text read</div><div class="v" id="n-bytes">0 GB</div><div class="r" id="r-bytes">&nbsp;</div></div>
74
+ <div class="num"><div class="k">Citations checked</div><div class="v" id="n-cites">0</div><div class="r" id="r-cites">&nbsp;</div></div>
75
+ <div class="num"><div class="k">Flagged</div><div class="v" id="n-flag">0</div><div class="r" id="r-flag">&nbsp;</div></div>
76
+ </div>
77
+ <div class="bar"><div id="prog"></div></div>
78
+ <div class="meta" id="meta"></div>
79
+
80
+ <div class="cols">
81
+ <div>
82
+ <h2>Citations going by</h2>
83
+ <div class="tape" id="tape"><div class="empty" style="border:0">Press Scan.</div></div>
84
+ </div>
85
+ <div>
86
+ <h2>Flagged</h2>
87
+ <ul class="caught" id="caught"></ul>
88
+ <div class="note">Unverified. Real opinions contain typos and OCR errors, and CiteNet's volume timelines are approximate, so some flags are its own mistakes.</div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ <script>
94
+ const $ = (id) => document.getElementById(id);
95
+ const esc = (s) => s.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n)).replace(/&amp;/g, "&")
96
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
97
+ const fmtB = (b) => b >= 1e9 ? (b / 1e9).toFixed(2) + " GB" : (b / 1e6).toFixed(0) + " MB";
98
+ const fmtR = (n) => n >= 1e6 ? (n / 1e6).toFixed(2) + "M" : n >= 1e3 ? Math.round(n / 1e3) + "k" : String(n);
99
+ let info = null, es = null, seen = new Set();
100
+
101
+ function reset() {
102
+ ["n-bytes", "n-cites", "n-flag"].forEach(id => $(id).textContent = "0");
103
+ $("n-bytes").textContent = "0 GB";
104
+ ["r-bytes", "r-cites", "r-flag"].forEach(id => $(id).innerHTML = "&nbsp;");
105
+ $("tape").innerHTML = ""; $("caught").innerHTML = ""; $("meta").textContent = ""; $("prog").style.width = "0";
106
+ seen = new Set();
107
+ }
108
+
109
+ function stop() { if (es) { es.close(); es = null; } $("go").textContent = info ? `Scan ${fmtB(info.bytes)}` : "Scan"; }
110
+
111
+ function onTick(ev) {
112
+ if (ev.start) return;
113
+ const loopRun = $("loop").checked;
114
+ $("n-bytes").textContent = fmtB(ev.bytes);
115
+ $("r-bytes").innerHTML = ev.done ? `in <b>${ev.t.toFixed(2)} s</b>` : `<b>${(ev.mbps / 1000).toFixed(2)} GB/s</b>`;
116
+ $("n-cites").textContent = ev.checked.toLocaleString();
117
+ $("r-cites").innerHTML = ev.done ? `<b>${fmtR(Math.round(ev.checked / ev.t))}/s</b> average` : `<b>${fmtR(ev.cps)}/s</b>`;
118
+ $("n-flag").textContent = ev.flagged.toLocaleString();
119
+ $("r-flag").innerHTML = ev.checked ? `${(100 * ev.flagged / ev.checked).toFixed(3)}% of citations` : "&nbsp;";
120
+ const total = info ? info.bytes : ev.bytes;
121
+ $("prog").style.width = loopRun ? "100%" : Math.min(100, 100 * ev.bytes / total) + "%";
122
+ const lanes = ev.ane + ev.gpu;
123
+ $("meta").textContent = (lanes ? `Neural Engine ${Math.round(100 * ev.ane / lanes)}% · GPU ${Math.round(100 * ev.gpu / lanes)}% · ` : "") +
124
+ `4 CPU scan threads · ${ev.t.toFixed(1)} s` + (loopRun ? ` · pass ${ev.pass + 1}` : "") +
125
+ (ev.done ? ` · done: ${info ? info.decisions.toLocaleString() + " decisions, " : ""}${ev.checked.toLocaleString()} citations` : "");
126
+ const tape = $("tape");
127
+ for (const s of ev.ticker) {
128
+ const d = document.createElement("div");
129
+ const before = s.before.slice(-34), after = s.after.slice(0, 40);
130
+ d.innerHTML = `…${esc(before)}<mark class="${s.flag ? "bad" : ""}">${esc(s.cite)}</mark>${esc(after)}…`;
131
+ tape.prepend(d);
132
+ }
133
+ while (tape.children.length > 40) tape.lastChild.remove();
134
+ const ul = $("caught");
135
+ for (const c of ev.caught) {
136
+ if (seen.has(c.cite)) continue;
137
+ seen.add(c.cite);
138
+ const li = document.createElement("li");
139
+ li.innerHTML = `<div class="c">${esc(c.cite)}</div><div class="w">${esc(c.why || c.kind)}</div>`;
140
+ ul.prepend(li);
141
+ }
142
+ while (ul.children.length > 14) ul.lastChild.remove();
143
+ if (ev.done) stop();
144
+ }
145
+
146
+ $("go").onclick = () => {
147
+ if (es) return stop();
148
+ reset();
149
+ $("go").textContent = "Stop";
150
+ es = new EventSource("/live/stream?loop=" + ($("loop").checked ? 1 : 0));
151
+ es.onmessage = (m) => onTick(JSON.parse(m.data));
152
+ es.onerror = () => stop();
153
+ };
154
+
155
+ fetch("/live/info").then(r => r.json()).then(j => {
156
+ if (j.error) { $("lede").innerHTML = "No corpus found (data/cold/corpus.txt). <a href='/'>Back</a>"; return; }
157
+ info = j;
158
+ $("lede").innerHTML = `${j.decisions.toLocaleString()} real US court decisions (${fmtB(j.bytes)}, COLD Cases, CC0), ` +
159
+ `scanned by a native scanner on the CPU and checked by CiteNet on the Neural Engine + GPU. <a href="/wall">Watch it read</a> · <a href="/">Back</a>`;
160
+ $("go").textContent = `Scan ${fmtB(j.bytes)}`;
161
+ if (new URLSearchParams(location.search).get("auto")) $("go").click();
162
+ });
163
+ </script>
164
+ </body>
165
+ </html>
demo/sample_brief.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ A complaint survives a motion to dismiss only if it states a claim that is plausible on its face. Bell Atl. Corp. v. Twombly, 550 U.S. 544, 570 (2007); Ashcroft v. Iqbal, 556 U.S. 662, 678 (2009). Courts in this circuit apply that rule strictly to carriage claims. Henley v. Northwind Cargo, Inc., 412 F.5th 1187, 1193 (2d Cir. 2023); see also Viacom Int'l, Inc. v. YouTube, Inc., 676 F.3d 19 (2d Cir. 2012).
2
+
3
+ Summary judgment is appropriate where there is no genuine dispute of material fact. Celotex Corp. v. Catrett, 477 U.S. 317, 322 (1986). District courts apply the same standard. Ruiz v. Pacific Lantern Co., 88 F. Supp. 3d 412, 418 (5th Cir. 2015); Thaler v. Perlmutter, 687 F. Supp. 3d 140 (D.D.C. 2023).
4
+
5
+ Expert testimony must rest on a reliable foundation. Daubert v. Merrell Dow Pharms., Inc., 509 U.S. 579, 589 (1993); Okafor v. Bellmont Labs., 912 F.3d 455, 460 (9th Cir. 1998). State courts agree. Hargrove v. Benton Freight, 702 So. 2d 115, 117 (Tex. App. 1997); Tarasoff v. Regents of Univ. of Cal., 551 P.2d 334 (Cal. 1976); Moreau v. Calder, 233 S.W.4th 905, 929 (Tex. 2002).
demo/sample_mata.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ The six citations ChatGPT invented for the plaintiff's brief in Mata v. Avianca, Inc., 678 F. Supp. 3d 443 (S.D.N.Y. 2023). None of these cases exist, but every citation copies a real format:
2
+
3
+ Varghese v. China Southern Airlines Co., 925 F.3d 1339 (11th Cir. 2019).
4
+ Shaboon v. Egyptair, 2013 IL App (1st) 111279-U (Ill. App. Ct. 2013).
5
+ Petersen v. Iran Air, 905 F. Supp. 2d 121 (D.D.C. 2012).
6
+ Martinez v. Delta Air Lines, Inc., 2019 WL 4639462 (Tex. App. Sept. 25, 2019).
7
+ Estate of Durden v. KLM Royal Dutch Airlines, 2017 WL 2418825 (Ga. Ct. App. June 5, 2017).
8
+ Miller v. United Airlines, Inc., 174 F.3d 366 (2d Cir. 1999).
9
+
10
+ A string-only model can only test whether a citation is structurally possible. Catching these needs a lookup against case law (for example the CourtListener citation API).
demo/server.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CiteNet demo server: live phantom-citation checking on Core ML.
2
+
3
+ python demo/server.py -> http://localhost:8025
4
+
5
+ Every request runs the hand-written model through Core ML: b1 on the CPU for a
6
+ single citation, b32 / b512 on the Neural Engine for more. Timings in the
7
+ responses are measured around MLModel.predict in this process. The firehose
8
+ runs the native Swift binary bench/firehose (ANE b512, GPU b8192, or both, two
9
+ threads per unit).
10
+ """
11
+ import json
12
+ import os
13
+ import random
14
+ import sys
15
+ import threading
16
+ import time
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+
19
+ import numpy as np
20
+ import coremltools as ct
21
+
22
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
23
+ sys.path.insert(0, ROOT)
24
+ sys.path.insert(0, os.path.join(ROOT, "scripts"))
25
+ from citenet import spec, text # noqa: E402
26
+ from citenet.net import knot_readout, K_REP, K_COURT, ANCHOR_W # noqa: E402
27
+ from lepc import load # noqa: E402
28
+
29
+ BUILD = os.path.join(ROOT, "build")
30
+ PORT = int(os.environ.get("PORT", 8025))
31
+
32
+ META = json.load(open(os.path.join(BUILD, "meta.json")))
33
+ W = np.load(os.path.join(BUILD, "weights.npz"))
34
+ CONCEPTS = META["concepts"]
35
+ EVIDENCE = META["evidence"]
36
+ EV_INFO = {e[0]: dict(weight=e[1], cap=e[2], text=e[3]) for e in spec.EVIDENCE}
37
+
38
+ MODELS = {
39
+ 1: ("CPU", ct.models.MLModel(os.path.join(BUILD, "CiteNet_b1.mlpackage"), compute_units=ct.ComputeUnit.CPU_ONLY)),
40
+ 32: ("ANE", ct.models.MLModel(os.path.join(BUILD, "CiteNet_b32.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)),
41
+ 512: ("ANE", ct.models.MLModel(os.path.join(BUILD, "CiteNet_b512.mlpackage"), compute_units=ct.ComputeUnit.CPU_AND_NE)),
42
+ }
43
+ LOCK = threading.Lock()
44
+ LAST_ACTIVE = [0.0] # time of the last wall request; the keep-alive runs while it is recent
45
+
46
+
47
+ def keep_warm():
48
+ """While a wall is open, run a tiny batch every 100 ms so the Neural Engine stays awake.
49
+ Idle for a few seconds, the ANE powers down and the next call pays ~15-20 ms to wake up;
50
+ warm, a 64 KB chunk takes ~1.5 ms. About 1% of the ANE's time; stops 8 s after the last request."""
51
+ pings = {b: np.zeros((b, 1, 1, spec.SEQ), np.float16) for b in (32, 512)}
52
+ while True:
53
+ time.sleep(0.1)
54
+ if time.time() - LAST_ACTIVE[0] < 8:
55
+ with LOCK:
56
+ for b, x in pings.items():
57
+ MODELS[b][1].predict({"codes": x})
58
+ TOTAL = sum(int(np.prod(v.shape)) for v in W.values())
59
+ NONZERO = sum(int((v != 0).sum()) for v in W.values())
60
+
61
+
62
+
63
+ def run(codes):
64
+ """codes [N,1,1,SEQ] float -> (p, evidence, concepts, model label, ms)."""
65
+ n = len(codes)
66
+ b = 1 if n == 1 else (32 if n <= 32 else 512)
67
+ unit, m = MODELS[b]
68
+ ps, evs, cs = [], [], []
69
+ t0 = time.perf_counter()
70
+ with LOCK:
71
+ for i in range(0, n, b):
72
+ chunk = codes[i:i + b]
73
+ x = np.zeros((b, 1, 1, spec.SEQ), np.float16)
74
+ x[:len(chunk)] = chunk
75
+ out = m.predict({"codes": x})
76
+ k = len(chunk)
77
+ ps.append(out["p_fake"][:k, 0]); evs.append(out["evidence"][:k]); cs.append(out["concepts"][:k])
78
+ ms = (time.perf_counter() - t0) * 1e3
79
+ if n == 0:
80
+ return np.zeros(0), np.zeros((0, len(EVIDENCE))), np.zeros((0, len(CONCEPTS))), f"b{b} {unit}", 0.0
81
+ return (np.concatenate(ps).astype(float), np.concatenate(evs).astype(float),
82
+ np.concatenate(cs).astype(float), f"b{b} · {unit}", ms)
83
+
84
+
85
+ def describe(p, ev, c):
86
+ act = {CONCEPTS[j]: float(c[j]) for j in np.nonzero(c > 0.5e-2)[0]}
87
+ status = "phantom" if p >= 0.5 else ("unknown" if p > 0.1 else "ok")
88
+ reasons = [dict(name=e, value=round(float(v), 2), text=EV_INFO[e]["text"],
89
+ logit=round(float(min(v, EV_INFO[e]["cap"]) * EV_INFO[e]["weight"]), 2))
90
+ for e, v in zip(EVIDENCE, ev) if v > 0.01]
91
+ vol = act.get("volume", 0.0)
92
+ year = act.get("year", 0.0) + 1700 if act.get("has_year", 0) > 0.5 else None
93
+ curve = next((n[3:] for n in act if n.startswith("is:") and n[3:] in spec.CURVES), None)
94
+ fired = [n for n in act if not n.startswith(("vol>", "is:", "has_", "known", "volume", "year", "vol4y", "future"))
95
+ and not n.endswith("^hi")]
96
+ fired += [n for n in act if n.startswith("region:")]
97
+ return dict(p=round(float(p), 4), status=status, reasons=reasons, volume=int(round(vol)) or None,
98
+ year=int(round(year)) if year else None, curve=curve, fired=fired,
99
+ knots=sum(1 for n in act if n.startswith("vol>")), logit_bias=spec.BIAS)
100
+
101
+
102
+ def filters_fired(cite):
103
+ """Which first-layer filters fired (recomputed on the host from the same weights, for display)."""
104
+ codes = text.encode([cite])[0, 0, 0].astype(int)
105
+ onehot = np.zeros((spec.NCHAR, spec.SEQ), np.float32)
106
+ onehot[codes, np.arange(spec.SEQ)] = 1
107
+ out = []
108
+ for conv, names, k, width in (("conv_rep", META["anchored"], K_REP, ANCHOR_W),
109
+ ("conv_float", META["floating"], K_REP, spec.SEQ),
110
+ ("conv_court", [n for n in META["features"] if n.startswith("court:")], K_COURT, spec.SEQ)):
111
+ w, b = W[conv + ".weight"][:, :, 0, :], W[conv + ".bias"]
112
+ for i, name in enumerate(names):
113
+ for pos in range(width - k + 1):
114
+ if (w[i] * onehot[:, pos:pos + k]).sum() + b[i] > 0.5:
115
+ out.append(dict(name=name, pos=pos - 1, len=int(-b[i] + 1)))
116
+ break
117
+ return out
118
+
119
+
120
+ CLASS_DESC = {
121
+ "SCOTUS": "a Supreme Court reporter", "FEDAPP": "a federal appeals reporter",
122
+ "FEDAPP_OLD": "a federal appeals reporter", "FEDDIST": "a district-court reporter",
123
+ "BANKR": "a bankruptcy reporter", "REGION": "a state-court reporter", "STATE": "a state-court reporter",
124
+ }
125
+ STATE_NAME = {"cal": "California", "n.y": "New York", "mass": "Massachusetts", "ill": "Illinois", "ind": "Indiana",
126
+ "va": "Virginia", "pa": "Pennsylvania", "n.j": "New Jersey", "wash": "Washington", "ohio": "Ohio",
127
+ "md": "Maryland", "n.c": "North Carolina", "ga": "Georgia", "kan": "Kansas", "mich": "Michigan",
128
+ "wis": "Wisconsin", "conn": "Connecticut", "tex": "Texas", "s.c": "South Carolina",
129
+ "r.i": "Rhode Island", "colo": "Colorado", "ariz": "Arizona", "or": "Oregon", "okla": "Oklahoma"}
130
+ REGION_NAME = {"A": "Atlantic", "NE": "North Eastern", "NW": "North Western", "P": "Pacific",
131
+ "SE": "South Eastern", "SW": "South Western", "SO": "Southern"}
132
+
133
+
134
+ def explain(d, cite):
135
+ """One plain sentence per evidence neuron that fired, filled in from the citation."""
136
+ import re
137
+ from citenet.net import curve_at
138
+ m = text.CITE_RE.match(cite.strip())
139
+ rep = m.group("rep") if m else "this reporter"
140
+ paren = (m.group("paren") or "").strip(" ()") if m else ""
141
+ court = re.sub(r"\s*(?:[A-Z][a-z]{2,4}\.? \d{1,2}, )?\d{4}$", "", paren).strip(" ,") or None
142
+ act = set(d["fired"])
143
+ cls = next((n[6:] for n in act if n.startswith("class:")), None)
144
+ out = []
145
+ for r in d["reasons"]:
146
+ n = r["name"]
147
+ if n == "phantom_series":
148
+ out.append(f"{rep} was never published")
149
+ elif n == "court_mismatch":
150
+ out.append(f"{rep} is {CLASS_DESC.get(cls, 'a reporter')}; it never prints {court or 'that court'} opinions")
151
+ elif n == "state_mismatch":
152
+ reg = next((n2[7:] for n2 in act if n2.startswith("region:")), None)
153
+ home = next((n2[5:] for n2 in act if n2.startswith("home:")), None)
154
+ if reg:
155
+ sts = [s.title() + ("" if s in spec.UNDOTTED else ".") for s in spec.REGION_STATES[reg]]
156
+ lst = ", ".join(sts[:-1]) + " and " + sts[-1] if len(sts) <= 5 else ", ".join(sts[:4]) + " and others"
157
+ out.append(f"{rep} ({REGION_NAME[reg]} Reporter) covers {lst}, not {court}")
158
+ else:
159
+ out.append(f"{rep} only prints {STATE_NAME.get(home, 'its own state')} cases, not {court}")
160
+ elif n == "volume_year" and any(r2["name"] == "volume_missing" for r2 in d["reasons"]):
161
+ continue
162
+ elif n == "volume_year" and d["curve"]:
163
+ c = spec.CURVES[d["curve"]]
164
+ exp = round(curve_at(c["pts"], min(d["volume"], c["vmax"])))
165
+ out.append(f"volume {d['volume']} of {rep} is from about {exp}, not {d['year']}")
166
+ elif n == "volume_year":
167
+ out.append(f"the Westlaw/Lexis number is from {d['volume']}, but the case is dated {d['year']}")
168
+ elif n == "volume_missing":
169
+ vmax = spec.CURVES[d["curve"]]["vmax"] if d["curve"] else None
170
+ out.append(f"{rep} has no volume {d['volume']}; it ends around volume {vmax}" if vmax else f"{rep} has no volume {d['volume']}")
171
+ elif n == "future_year":
172
+ out.append(f"dated {d['year']}, which is in the future")
173
+ elif n == "unknown_reporter":
174
+ out.append(f"“{rep}” is not a reporter I recognize")
175
+ return out
176
+
177
+
178
+ def check_text(t):
179
+ t0 = time.perf_counter()
180
+ found = text.extract(t)
181
+ codes = text.encode([c for _, _, c in found])
182
+ t1 = time.perf_counter()
183
+ p, ev, c, label, ms = run(codes)
184
+ items = []
185
+ for (s, e, cite), pi, evi, ci in zip(found, p, ev, c):
186
+ d = describe(pi, evi, ci)
187
+ d.update(start=s, end=e, cite=cite, norm=text.normalize(cite))
188
+ d["why"] = explain(d, cite)
189
+ items.append(d)
190
+ return dict(items=items, n=len(items), model=label, model_ms=round(ms, 3),
191
+ tokenize_ms=round((t1 - t0) * 1e3, 3))
192
+
193
+
194
+ def curve_payload(key):
195
+ c = spec.CURVES[key]
196
+ a, coef = knot_readout(c["pts"], c["vmax"])
197
+ vs = np.linspace(0, max(10, c["vmax"]), 120)
198
+ net = [a + sum(w * max(0.0, v - float(n[4:])) for n, w in coef.items()) + 1700 for v in vs]
199
+ return dict(key=key, tol=c["tol"], vmax=c["vmax"], anchors=c["pts"],
200
+ net=[[float(v), float(y)] for v, y in zip(vs, net)],
201
+ reporters=[a for a, _, _, k in spec.REPORTERS if k == key])
202
+
203
+
204
+ def _label(rs, labels):
205
+ if len(rs) == 1:
206
+ return labels[rs[0]]
207
+ digits = [r for r in rs if spec.DIGIT0 <= r < spec.DIGIT0 + 10]
208
+ letters = [r for r in rs if spec.LETTER_A <= r < spec.LETTER_A + 26]
209
+ rest = [labels[r] for r in rs if r not in digits and r not in letters]
210
+ out = []
211
+ if len(digits) == 10:
212
+ out.append("0-9")
213
+ elif len(digits) >= 6:
214
+ out.append("0-9*")
215
+ else:
216
+ out += [labels[r] for r in digits]
217
+ if len(letters) >= 20:
218
+ out.append("a-z" if len(letters) == 26 else "a-z*")
219
+ else:
220
+ out += [labels[r] for r in letters]
221
+ if len(rest) > 6:
222
+ return "other"
223
+ return " ".join(out + rest)
224
+
225
+
226
+ def filter_payload(name):
227
+ conv, names, k = None, None, None
228
+ for cv, ns, kk in (("conv_rep", META["anchored"], K_REP), ("conv_float", META["floating"], K_REP),
229
+ ("conv_court", [n for n in META["features"] if n.startswith("court:")], K_COURT)):
230
+ if name in ns:
231
+ conv, names, k = cv, ns, kk
232
+ if conv is None:
233
+ return None
234
+ i = names.index(name)
235
+ w = W[conv + ".weight"][i, :, 0, :]
236
+ labels = ["pad", "bos", "eos"] + [str(d) for d in range(10)] + [chr(97 + j) for j in range(26)] + \
237
+ ["␣", ".", ",", "(", ")", "'", "&", "-", "?"]
238
+ # merge characters with identical weight rows ("0-9", "a-z") and order rows by
239
+ # their first column, so a template reads as a diagonal
240
+ groups = {}
241
+ for r in range(spec.NCHAR):
242
+ if w[r].any():
243
+ groups.setdefault(tuple(int(x) for x in w[r]), []).append(r)
244
+ rows = []
245
+ for vec, rs in sorted(groups.items(), key=lambda kv: (kv[0].index(1), kv[1][0])):
246
+ rows.append(dict(ch=_label(rs, labels), w=list(vec)))
247
+ return dict(name=name, k=k, bias=float(W[conv + ".bias"][i]), rows=rows, layer=conv,
248
+ params=int(w.size) + 1, nonzero=int((w != 0).sum()))
249
+
250
+
251
+ # ---------------------------------------------------------------- samples and benchmarks
252
+ SAMPLES = {
253
+ "brief": open(os.path.join(ROOT, "demo", "sample_brief.txt")).read(),
254
+ "mata": open(os.path.join(ROOT, "demo", "sample_mata.txt")).read(),
255
+ }
256
+ EVAL_ROWS = [json.loads(l) for l in open(os.path.join(ROOT, "data", "eval.jsonl"))]
257
+ EVAL_CITES = load("eval")
258
+ ALL_CITES = load("aux_train") + EVAL_CITES
259
+ ALL_CODES = text.encode([r["cite"] for r in ALL_CITES]).astype(np.float16)
260
+
261
+
262
+ def lepc_sample():
263
+ r = random.choice([r for r in EVAL_ROWS if any("non_existent_citation" in v
264
+ for v in (r.get("list_hallucination_types") or {}).values())])
265
+ return dict(text=r["text"], file=r["filename"],
266
+ planted={k: v for k, v in (r.get("list_hallucinations") or {}).items()})
267
+
268
+
269
+ def bench_eval():
270
+ codes = text.encode([r["cite"] for r in EVAL_CITES])
271
+ p, _, _, label, ms = run(codes)
272
+ lab = np.array([r["label"] for r in EVAL_CITES]); flag = p >= 0.5
273
+ tp = int((flag & (lab == "phantom")).sum()); P = int((lab == "phantom").sum())
274
+ fp = int((flag & (lab == "genuine")).sum()); G = int((lab == "genuine").sum())
275
+ flagged = [r["cite"] for r, f in zip(EVAL_CITES, flag) if f and r["label"] == "genuine"]
276
+ return dict(n=len(EVAL_CITES), model=label, ms=round(ms, 2), recall=[tp, P], false_flags=[fp, G],
277
+ false_flag_cites=flagged)
278
+
279
+
280
+ FIREHOSE = os.path.join(ROOT, "bench", "firehose")
281
+
282
+
283
+ def firehose(n, lanes):
284
+ """Native Swift Core ML firehose (bench/firehose.swift): Python threads cannot keep the
285
+ ANE and the GPU busy at the same time, a native process can."""
286
+ import subprocess
287
+ if lanes not in ("ane", "gpu", "ane+gpu"):
288
+ lanes = "ane+gpu"
289
+ with LOCK:
290
+ out = subprocess.run([FIREHOSE, BUILD, lanes, str(n)], capture_output=True, text=True, timeout=120)
291
+ return json.loads(out.stdout)
292
+
293
+
294
+ STREAM = os.path.join(ROOT, "bench", "stream_bin")
295
+ CORPUS = os.path.join(ROOT, "data", "cold", "corpus.txt")
296
+ EV_SHORT = ["never-published series", "court mismatch", "state mismatch", "year doesn't fit volume",
297
+ "volume doesn't exist", "future date", "unknown reporter"]
298
+
299
+
300
+ def corpus_info():
301
+ try:
302
+ return json.load(open(CORPUS.rsplit(".", 1)[0] + ".json"))
303
+ except OSError:
304
+ return None
305
+
306
+
307
+ def live_stream(handler, loop):
308
+ """Run bench/stream_bin over the case-law corpus; relay its JSON lines as server-sent events."""
309
+ import subprocess
310
+ cmd = [STREAM, BUILD, CORPUS] + (["--loop"] if loop else [])
311
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
312
+ handler.send_response(200)
313
+ handler.send_header("Content-Type", "text/event-stream")
314
+ handler.send_header("Cache-Control", "no-store")
315
+ handler.end_headers()
316
+ try:
317
+ for line in proc.stdout:
318
+ ev = json.loads(line)
319
+ for c in ev.get("caught", []):
320
+ items = check_text(c["cite"])["items"]
321
+ c["why"] = "; ".join(items[0]["why"]) if items else EV_SHORT[c["ev"]]
322
+ c["kind"] = EV_SHORT[c["ev"]]
323
+ handler.wfile.write(b"data: " + json.dumps(ev).encode() + b"\n\n")
324
+ handler.wfile.flush()
325
+ if ev.get("done"):
326
+ break
327
+ except (BrokenPipeError, ConnectionResetError):
328
+ pass
329
+ finally:
330
+ proc.kill()
331
+ proc.wait()
332
+
333
+
334
+ # ---------------------------------------------------------------- text wall
335
+ import mmap
336
+ WALL_SRC = {"cold": CORPUS, "lepc": os.path.join(BUILD, "lepc_text.txt")}
337
+ _maps = {}
338
+
339
+
340
+ def _wall_map(src):
341
+ if src not in _maps:
342
+ f = open(WALL_SRC[src], "rb")
343
+ _maps[src] = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
344
+ return _maps[src]
345
+
346
+
347
+ def wall_random(src):
348
+ """Byte offset of the start of a random decision / brief excerpt."""
349
+ src = "cold" if src == "planted" else src
350
+ m = _wall_map(src)
351
+ off = random.randrange(0, max(1, len(m) - 100_000))
352
+ i = m.find(b"\n\n\n" if src == "cold" else b"\n\n", off)
353
+ return dict(off=0 if i < 0 else i + (3 if src == "cold" else 2))
354
+
355
+
356
+ # ---- planted fakes: mutate real citations into impossible ones, then let the model find them
357
+ import re as _re
358
+ CURVE_REPS = {a for a, _, _, k in spec.REPORTERS if k and k in spec.CURVES}
359
+ FIRST_SERIES = {"F.", "A.", "P.", "So.", "N.E.", "N.W.", "S.E.", "S.W."}
360
+ FED_APP = {"f.3d", "f.2d", "f.4th", "f.app'x", "fed.appx."}
361
+ STATE_COURTS = ["Tex. App.", "Cal. Ct. App.", "N.Y. App. Div.", "Fla. Dist. Ct. App.", "Ill. App. Ct."]
362
+ _SERIES = _re.compile(r"^(?P<base>.*?)(?P<sp>\s?)(?P<ord>2d|3d|4th|5th|2nd|3rd)$")
363
+
364
+
365
+ PLANT_RATE = 0.125 # target share of planted citations (a quota: unmutable ones pass it to the next)
366
+
367
+
368
+ def plant(cite, rng):
369
+ """One impossible variant of a real citation, or None if none of the mutations applies."""
370
+ m = text.CITE_RE.match(cite)
371
+ if not m:
372
+ return None
373
+ rep = m.group("rep")
374
+ norm = text.normalize(rep + " 1").rsplit(" ", 1)[0]
375
+ paren = m.group("paren") or ""
376
+ yr = _re.search(r"(?<=[\s(.,])(\d{4})\)\s*$", paren) # a real year, not "*7771985"
377
+ opts = []
378
+ sm = None if "A.L.R" in rep or "ALR" in rep else _SERIES.match(rep) # A.L.R. really runs to 7th
379
+ if sm:
380
+ bump = {"2d": "4th", "2nd": "4th", "3d": rng.choice(["4th", "5th"]), "3rd": "5th", "4th": "6th", "5th": "7th"}[sm["ord"]]
381
+ if not (norm in ("f.3d", "f.supp.3d") and bump == "4th"): # F.4th is real
382
+ opts += [("series", rep[:sm.start("ord")] + bump)] * 3
383
+ elif rep in FIRST_SERIES: # F.4th is real, so the first Federal Reporter jumps to 5th/6th
384
+ opts += [("series", rep + (rng.choice(["5th", "6th"]) if rep == "F." else rng.choice(["4th", "5th"])))] * 3
385
+ if yr and norm in CURVE_REPS:
386
+ y = int(yr.group(1))
387
+ ny = y + rng.choice([-1, 1]) * rng.randint(18, 40)
388
+ if not 1800 <= ny <= 2024:
389
+ ny = y - rng.randint(18, 40) if ny > 2024 else y + rng.randint(18, 40)
390
+ if 1800 <= ny <= 2024:
391
+ opts += [("year", None, ny)] * 2
392
+ if paren and "Cir." in paren and norm in FED_APP:
393
+ opts.append(("court", rng.choice(STATE_COURTS)))
394
+ if not opts:
395
+ return None
396
+ kind, *val = rng.choice(opts)
397
+ if kind == "series":
398
+ a, b = m.span("rep")
399
+ fake_rep = val[0]
400
+ # never produce a series that really exists (F.4th, Cal. 4th/5th, Cal. App. 4th/5th)
401
+ real = set(spec.VALID_4TH) | set(spec.VALID_5TH)
402
+ if text.normalize(fake_rep + " 1").rsplit(" ", 1)[0] in real:
403
+ fake_rep = _re.sub(r"(4th|5th)$", "6th", fake_rep)
404
+ return cite[:a] + fake_rep + cite[b:]
405
+ if kind == "year":
406
+ a = m.start("paren") + yr.start(1)
407
+ return cite[:a] + str(val[1]) + cite[a + 4:]
408
+ if not yr:
409
+ return None
410
+ new_paren = paren[:paren.index("(") + 1] + val[0] + " " + yr.group(1) + ")"
411
+ return cite[:m.start("paren")] + new_paren + cite[m.end("paren"):]
412
+
413
+
414
+ def plant_text(t, rate, seed):
415
+ """Replace ~rate of the citations in t with planted fakes. Returns (new text, planted ranges)."""
416
+ rng = random.Random(seed)
417
+ out, ranges, pos = [], [], 0
418
+ owed = rng.random() # quota carried across citations, so density stays near `rate`
419
+ for s_, e_, cite in text.extract(t):
420
+ owed = min(owed + rate, 3.0)
421
+ if owed < 1 or rng.random() < 0.3: # a little jitter so fakes are not evenly spaced
422
+ continue
423
+ fake = plant(cite, rng)
424
+ if not fake or fake == cite:
425
+ continue
426
+ owed -= 1
427
+ out.append(t[pos:s_])
428
+ start = sum(len(x) for x in out)
429
+ out.append(fake)
430
+ ranges.append((start, start + len(fake)))
431
+ pos = e_
432
+ out.append(t[pos:])
433
+ return "".join(out), ranges
434
+
435
+
436
+ def wall_chunk(src, off, size):
437
+ """The next ~size bytes of text from `off` (ending on a line break), with every citation
438
+ checked by the Core ML model. Returns text plus spans [start, end, status, why]."""
439
+ planting = src == "planted"
440
+ if planting:
441
+ src = "cold"
442
+ m = _wall_map(src)
443
+ n = len(m)
444
+ off %= n
445
+ end = m.find(b"\n", min(n, off + size))
446
+ end = n if end < 0 else end + 1
447
+ raw = m[off:end]
448
+ nxt = end if end < n else 0 # loop at the end of the corpus
449
+ t = raw.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n").replace("\x0c", "\n")
450
+ ranges = []
451
+ if planting: # the model below only ever sees the mutated text
452
+ t, ranges = plant_text(t, PLANT_RATE, seed=off)
453
+ t0 = time.perf_counter()
454
+ found = text.extract(t)
455
+ codes = text.encode([c for _, _, c in found])
456
+ t1 = time.perf_counter()
457
+ p, ev, c, label, ms = run(codes)
458
+ spans, flagged, caught, missed = [], 0, 0, 0
459
+ for (s_, e_, cite), pi, evi, ci in zip(found, p, ev, c):
460
+ status = "phantom" if pi >= 0.5 else ("unknown" if pi > 0.1 else "ok")
461
+ why = ""
462
+ if status != "ok":
463
+ d = describe(pi, evi, ci)
464
+ why = "; ".join(explain(d, cite))
465
+ flagged += status == "phantom"
466
+ planted = any(a < e_ and s_ < b for a, b in ranges)
467
+ caught += planted and status == "phantom"
468
+ missed += planted and status != "phantom"
469
+ spans.append([s_, e_, status, why, planted])
470
+ return dict(text=t, spans=spans, next=nxt, bytes=len(raw), n=len(found), flagged=flagged,
471
+ planted=len(ranges), caught=caught, missed=missed,
472
+ model=label, model_ms=round(ms, 3), tokenize_ms=round((t1 - t0) * 1e3, 3))
473
+
474
+
475
+ SWIFT = open(os.path.join(ROOT, "results", "bench_swift.txt")).read()
476
+
477
+
478
+ def swift_headline():
479
+ """Pull the header-card numbers out of the native benchmark log."""
480
+ import re
481
+ out = {}
482
+ for line in SWIFT.splitlines():
483
+ m = re.match(r"b(\d+)\s+(\w+)\s+p50\s+([\d.]+) us.*?(\d+) cites/s", line)
484
+ if m:
485
+ out[f"b{m[1]}_{m[2]}"] = dict(p50_us=float(m[3]), per_sec=int(m[4]))
486
+ m = re.match(r"concurrent (.*) -> (\d+) cites/s", line)
487
+ if m:
488
+ out["concurrent:" + re.sub(r" [\d.]+k/s", "", m[1])] = int(m[2])
489
+ return out
490
+
491
+
492
+ def info():
493
+ return dict(params=TOTAL, nonzero=NONZERO, evidence=[dict(name=e[0], weight=e[1], cap=e[2], text=e[3]) for e in spec.EVIDENCE],
494
+ bias=spec.BIAS, swift=SWIFT, headline=swift_headline(),
495
+ layers=dict(rep=len([n for n in META["features"] if n.startswith(("rep:", "series:", "other:"))]),
496
+ court=len([n for n in META["features"] if n.startswith("court:")]),
497
+ concepts=len(CONCEPTS), rules=len(META["rules"]), evidence=len(EVIDENCE)),
498
+ curves=sorted(spec.CURVES))
499
+
500
+
501
+ class H(BaseHTTPRequestHandler):
502
+ def log_message(self, *a):
503
+ pass
504
+
505
+ def send(self, obj, code=200, ctype="application/json"):
506
+ body = obj if isinstance(obj, bytes) else json.dumps(obj).encode()
507
+ self.send_response(code)
508
+ self.send_header("Content-Type", ctype)
509
+ self.send_header("Content-Length", str(len(body)))
510
+ self.send_header("Cache-Control", "no-store")
511
+ self.end_headers()
512
+ self.wfile.write(body)
513
+
514
+ def do_GET(self):
515
+ path, _, q = self.path.partition("?")
516
+ args = dict(kv.split("=", 1) for kv in q.split("&") if "=" in kv)
517
+ from urllib.parse import unquote
518
+ if path in ("/", "/index.html"):
519
+ return self.send(open(os.path.join(ROOT, "demo", "index.html"), "rb").read(), ctype="text/html; charset=utf-8")
520
+ if path == "/wall":
521
+ return self.send(open(os.path.join(ROOT, "demo", "wall.html"), "rb").read(), ctype="text/html; charset=utf-8")
522
+ if path == "/wall/random":
523
+ return self.send(wall_random(args.get("src", "cold")))
524
+ if path == "/wall/chunk":
525
+ LAST_ACTIVE[0] = time.time()
526
+ return self.send(wall_chunk(args.get("src", "cold"), int(args.get("off", 0)),
527
+ min(int(args.get("size", 16384)), 1 << 20)))
528
+ if path == "/live":
529
+ return self.send(open(os.path.join(ROOT, "demo", "live.html"), "rb").read(), ctype="text/html; charset=utf-8")
530
+ if path == "/live/info":
531
+ return self.send(corpus_info() or {"error": "no corpus"})
532
+ if path == "/live/stream":
533
+ return live_stream(self, args.get("loop") == "1")
534
+ if path == "/inspect": # detailed view: neurons, filters, volume/year curves
535
+ return self.send(open(os.path.join(ROOT, "demo", "inspect.html"), "rb").read(), ctype="text/html; charset=utf-8")
536
+ if path == "/info":
537
+ return self.send(info())
538
+ if path == "/sample":
539
+ kind = args.get("kind", "brief")
540
+ return self.send(lepc_sample() if kind == "lepc" else dict(text=SAMPLES[kind]))
541
+ if path == "/curve":
542
+ return self.send(curve_payload(unquote(args["key"])))
543
+ if path == "/filter":
544
+ f = filter_payload(unquote(args["name"]))
545
+ return self.send(f) if f else self.send({"error": "no such filter"}, 404)
546
+ if path == "/fired":
547
+ return self.send(filters_fired(unquote(args["cite"])))
548
+ if path == "/bench":
549
+ return self.send(bench_eval())
550
+ if path == "/firehose":
551
+ return self.send(firehose(min(int(args.get("n", 1000000)), 20_000_000), unquote(args.get("lanes", "ane+gpu"))))
552
+ self.send({"error": "not found"}, 404)
553
+
554
+ def do_POST(self):
555
+ n = int(self.headers.get("Content-Length", 0))
556
+ body = json.loads(self.rfile.read(n) or b"{}")
557
+ if self.path == "/check":
558
+ return self.send(check_text(body.get("text", "")))
559
+ self.send({"error": "not found"}, 404)
560
+
561
+
562
+ if __name__ == "__main__":
563
+ run(text.encode(["347 U.S. 483 (1954)"])) # warm up all three models
564
+ run(text.encode(["347 U.S. 483 (1954)"] * 20))
565
+ run(text.encode(["347 U.S. 483 (1954)"] * 100))
566
+ threading.Thread(target=keep_warm, daemon=True).start()
567
+ print(f"CiteNet demo on http://localhost:{PORT} ({TOTAL:,} params, {NONZERO:,} nonzero, all hand-written)")
568
+ ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
demo/wall.html ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CiteNet Wall</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f7f6f2; --panel: #ffffff; --ink: #1c1b19; --muted: #77746b; --faint: #8f8b80; --line: #e6e3da;
10
+ --ok: #1f7a4d; --ok-bg: #dcefe3; --bad: #c2321f; --bad-bg: #f9dcd6; --warn: #a86a00; --warn-bg: #f8ebcc;
11
+ --mono: ui-monospace, "SF Mono", Menlo, monospace;
12
+ --serif: "Iowan Old Style", Palatino, Georgia, serif;
13
+ }
14
+ @media (prefers-color-scheme: dark) {
15
+ :root {
16
+ --bg: #121210; --panel: #1a1917; --ink: #ecebe6; --muted: #918e85; --faint: #8a877e; --line: #2b2a26;
17
+ --ok: #4fc487; --ok-bg: #1b3325; --bad: #ff7a66; --bad-bg: #4a221b; --warn: #f0b545; --warn-bg: #3b2f17;
18
+ }
19
+ }
20
+ * { box-sizing: border-box; }
21
+ html, body { height: 100%; }
22
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
23
+ display: flex; flex-direction: column; overflow: hidden; }
24
+ header { padding: 14px 22px 10px; display: flex; gap: 10px 18px; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--line); }
25
+ h1 { margin: 0; font-size: 20px; letter-spacing: -0.02em; }
26
+ .group { display: flex; gap: 4px; align-items: center; }
27
+ .group span { color: var(--muted); font-size: 12px; margin-right: 4px; }
28
+ button { font: inherit; font-size: 13px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); padding: 4px 10px; border-radius: 999px; cursor: pointer; }
29
+ button.on { border-color: var(--ink); background: var(--ink); color: var(--bg); }
30
+ header a { color: var(--muted); font-size: 13px; }
31
+ .stats { padding: 8px 22px; display: flex; gap: 6px 22px; flex-wrap: wrap; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; border-bottom: 1px solid var(--line); }
32
+ .stats b { color: var(--ink); font-weight: 600; }
33
+ .stats .bad { color: var(--bad); }
34
+ main { flex: 1; display: flex; min-height: 0; }
35
+ .viewport { flex: 1; overflow: hidden; position: relative; cursor: pointer; min-width: 0;
36
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 6%, #000 88%, transparent 100%);
37
+ mask-image: linear-gradient(to bottom, transparent 0, #000 6%, #000 88%, transparent 100%); }
38
+ .wall { position: absolute; left: 0; right: 0; top: 0; will-change: transform; }
39
+ .chunk { max-width: 1180px; margin: 0 auto; padding: 0 22px; font: 14.5px/1.62 var(--serif); color: var(--faint); white-space: pre-wrap; overflow-wrap: break-word; }
40
+ .case { display: inline-block; color: var(--ink); font: 600 13px/1.4 -apple-system, system-ui, sans-serif; margin-top: 10px; }
41
+ mark { border-radius: 3px; padding: 0 1px; color: var(--ink); }
42
+ mark.ok { background: var(--ok-bg); }
43
+ mark.unknown { background: var(--warn-bg); }
44
+ mark.phantom { background: var(--bad-bg); box-shadow: inset 0 -2px 0 var(--bad); font-weight: 600; }
45
+ mark.missed { outline: 2px dashed var(--bad); }
46
+ .tag.miss { background: var(--muted); }
47
+ .q .b { font: 600 10px/1 -apple-system, system-ui, sans-serif; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-left: 6px; }
48
+ .tag { font: 600 11px/1 -apple-system, system-ui, sans-serif; color: #fff; background: var(--bad); border-radius: 4px; padding: 2px 6px; margin-left: 4px; white-space: nowrap; vertical-align: 1px; }
49
+ .readline { position: absolute; left: 0; right: 0; top: 55%; border-top: 1px dashed var(--line); pointer-events: none; }
50
+ .readline span { position: absolute; right: 10px; top: -9px; font-size: 11px; color: var(--muted); background: var(--bg); padding: 0 4px; }
51
+ mark.hit { animation: hit .6s ease-out; }
52
+ @keyframes hit { from { outline: 3px solid var(--ink); } to { outline: 3px solid transparent; } }
53
+ aside { width: 380px; border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; background: var(--panel); }
54
+ aside h2 { margin: 0; padding: 10px 14px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); font-weight: 600;
55
+ display: flex; justify-content: space-between; }
56
+ aside h2 b { color: var(--ink); font-variant-numeric: tabular-nums; }
57
+ .reds { max-height: 38%; overflow-y: auto; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
58
+ .reds .empty { color: var(--muted); font-size: 13px; padding: 2px 14px 6px; }
59
+ .lights { flex: 1; overflow: hidden; -webkit-mask-image: linear-gradient(to bottom, #000 75%, transparent); mask-image: linear-gradient(to bottom, #000 75%, transparent); }
60
+ .q { display: grid; grid-template-columns: 9px minmax(0, 1fr); gap: 1px 9px; padding: 4px 14px; align-items: baseline; }
61
+ .q i { width: 9px; height: 9px; border-radius: 50%; background: var(--ok); display: inline-block; }
62
+ .q.unknown i { background: var(--warn); }
63
+ .q.phantom i { background: var(--bad); }
64
+ .q.missedrow i { background: transparent; border: 2px solid var(--bad); }
65
+ .q .c { font: 12.5px/1.35 var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
66
+ .q.phantom .c { color: var(--bad); white-space: normal; overflow-wrap: anywhere; }
67
+ .q .w { grid-column: 2; color: var(--muted); font-size: 12.5px; line-height: 1.35; }
68
+ .q.new { animation: slide .35s ease-out; }
69
+ @keyframes slide { from { background: var(--line); } to { background: transparent; } }
70
+ button.play { font-weight: 600; border-color: var(--bad); color: var(--bad); }
71
+ button.play.on { background: var(--bad); color: #fff; border-color: var(--bad); }
72
+ .runbar { position: absolute; left: 0; top: 0; height: 3px; background: var(--bad); width: 0; z-index: 2; }
73
+ .summary { position: absolute; left: 50%; top: 38%; transform: translate(-50%, -50%); z-index: 3; display: none;
74
+ background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px 26px; text-align: center;
75
+ box-shadow: 0 10px 40px rgba(0,0,0,.35); min-width: 360px; cursor: pointer; }
76
+ .summary .t { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
77
+ .summary .big { font-size: 34px; font-weight: 700; letter-spacing: -0.02em; margin: 4px 0 8px; font-variant-numeric: tabular-nums; }
78
+ .summary .row { color: var(--muted); font-size: 14px; font-variant-numeric: tabular-nums; line-height: 1.7; }
79
+ .summary .row b { color: var(--ink); }
80
+ .summary .row .bad { color: var(--bad); }
81
+ .summary .hint { color: var(--faint); font-size: 12px; margin-top: 8px; }
82
+ .paused { position: absolute; right: 22px; top: 14px; font-size: 12px; color: var(--muted); background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; display: none; }
83
+ @media (max-width: 900px) {
84
+ main { flex-direction: column; }
85
+ aside { width: auto; height: 34vh; border-left: 0; border-top: 1px solid var(--line); }
86
+ header, .stats { padding-left: 16px; padding-right: 16px; } .chunk { padding: 0 16px; font-size: 14px; }
87
+ }
88
+ </style>
89
+ </head>
90
+ <body>
91
+ <header>
92
+ <h1>CiteNet Wall</h1>
93
+ <div class="group"><span>Text</span>
94
+ <button data-src="planted" class="on" title="real opinions with ~1 in 11 citations mutated into impossible ones">Opinions + planted fakes</button>
95
+ <button data-src="cold">Court opinions</button>
96
+ <button data-src="lepc">LePhantomCite briefs</button>
97
+ </div>
98
+ <div class="group"><span>Speed</span>
99
+ <button data-speed="45">Read</button>
100
+ <button data-speed="400" class="on">Fast</button>
101
+ <button data-speed="2500">Faster</button>
102
+ <button data-speed="9000">Max</button>
103
+ </div>
104
+ <button id="play" class="play" title="fresh run for exactly 30 seconds at the selected speed, then stop">▶ 30 s</button>
105
+ <button id="pause" title="pause / resume (space, or click the text)">❚❚ Pause</button>
106
+ <a href="/live">Full-speed scan</a> <a href="/">Back</a>
107
+ </header>
108
+ <div class="stats" id="stats">&nbsp;</div>
109
+ <main>
110
+ <div class="viewport" id="vp">
111
+ <div class="wall" id="wall"></div>
112
+ <div class="readline"><span>citations join the queue here</span></div>
113
+ <div class="paused" id="paused">paused</div>
114
+ <div class="runbar" id="runbar"></div>
115
+ <div class="summary" id="summary"></div>
116
+ </div>
117
+ <aside>
118
+ <h2><span>Phantoms</span><b id="n-red">0</b></h2>
119
+ <div class="reds" id="reds"><div class="empty">None yet.</div></div>
120
+ <h2><span>Queue</span><b id="n-lit">0</b></h2>
121
+ <div class="lights" id="lights"></div>
122
+ </aside>
123
+ </main>
124
+
125
+ <script>
126
+ const $ = (id) => document.getElementById(id);
127
+ const vp = $("vp"), wall = $("wall");
128
+ const ENT = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", sect: "\u00a7", para: "\u00b6" };
129
+ const unent = (s) => s.replace(/&(#\d+|[a-z]+);/g, (m, e) => e[0] === "#" ? String.fromCharCode(+e.slice(1)) : (ENT[e] ?? m));
130
+ const esc = (s) => unent(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
131
+ let pending = [], lit = 0, reds = 0;
132
+ const RUN_S = 30;
133
+ let runLeft = 0; // seconds left in a 30 s run (0 = not in a run)
134
+ let src = "planted", speed = 400, y = 0, next = 0, inflight = false, paused = false, gen = 0;
135
+ let tot = { bytes: 0, cites: 0, flagged: 0, chunks: 0, planted: 0, caught: 0, missed: 0 }, recentMs = [], lastModel = "", px = 0, lastPx = 0, lastT = performance.now();
136
+
137
+ function render(c, first) {
138
+ const t = c.text; let h = "", pos = 0;
139
+ for (const [s, e, st, why, planted] of c.spans) {
140
+ const cls = st + (planted ? " planted" : "") + (planted && st !== "phantom" ? " missed" : "");
141
+ h += esc(t.slice(pos, s)) + `<mark class="${cls}" data-why="${esc(why)}"${why ? ` title="${esc(why)}"` : ""}>${esc(t.slice(s, e))}</mark>`;
142
+ if (st === "phantom") h += `<span class="tag">${planted ? "planted · " : ""}${esc(why)}</span>`;
143
+ else if (planted) h += `<span class="tag miss">missed planted fake</span>`;
144
+ pos = e;
145
+ }
146
+ h += esc(t.slice(pos));
147
+ // a decision starts after a triple line break with a "case name, court, date" line
148
+ h = h.replace(/\n\n\n([^\n<]{3,220})\n/g, "\n\n\n<span class=\"case\">$1</span>\n");
149
+ if (first && src === "cold") h = h.replace(/^([^\n<]{3,220})\n/, "<span class=\"case\">$1</span>\n");
150
+ const d = document.createElement("div");
151
+ d.className = "chunk"; d.innerHTML = h;
152
+ return d;
153
+ }
154
+
155
+ async function fetchMore(g) {
156
+ inflight = true;
157
+ const size = speed >= 2500 ? 65536 : 12288; // bigger chunks when scrolling fast
158
+ const c = await fetch(`/wall/chunk?src=${src}&off=${next}&size=${size}`).then(r => r.json());
159
+ inflight = false;
160
+ if (g !== gen) return;
161
+ next = c.next;
162
+ const d = render(c, tot.chunks === 0); d.dataset.bytes = c.bytes;
163
+ wall.appendChild(d);
164
+ pending.push(...d.querySelectorAll("mark"));
165
+ tot.bytes += c.bytes; tot.cites += c.n; tot.flagged += c.flagged; tot.chunks += 1;
166
+ tot.planted += c.planted || 0; tot.caught += c.caught || 0; tot.missed += c.missed || 0;
167
+ recentMs.push([c.model_ms, c.bytes]); if (recentMs.length > 25) recentMs.shift();
168
+ lastModel = c.model;
169
+ }
170
+
171
+ function stats() {
172
+ // scroll rate in bytes: pixels scrolled per second times bytes per pixel of the text on screen
173
+ const now = performance.now(), dt = (now - lastT) / 1000;
174
+ const pps = (px - lastPx) / Math.max(dt, 1e-3); lastPx = px; lastT = now;
175
+ let b = 0; for (const ch of wall.children) b += +ch.dataset.bytes || 0;
176
+ const kbs = pps * (b / Math.max(1, wall.scrollHeight)) / 1000;
177
+ const unit = lastModel.includes("ANE") ? "Neural Engine" : "CPU";
178
+ const sorted = [...recentMs].sort((a, b) => a[0] - b[0]), mid = sorted[sorted.length >> 1] || [0, 0];
179
+ const per = mid[0], chunkKB = Math.round(mid[1] / 1024);
180
+ $("stats").innerHTML =
181
+ `<span><b>${(tot.bytes / 1e6).toFixed(2)} MB</b> read</span>` +
182
+ `<span><b>${tot.cites.toLocaleString()}</b> citations highlighted</span>` +
183
+ (src === "planted"
184
+ ? `<span><b>${tot.planted.toLocaleString()}</b> fakes planted</span><span class="bad"><b class="bad">${tot.caught.toLocaleString()}</b> caught</span>` +
185
+ `<span><b>${tot.missed.toLocaleString()}</b> missed</span><span><b>${(tot.flagged - tot.caught).toLocaleString()}</b> found in the source</span>`
186
+ : `<span class="bad"><b class="bad">${tot.flagged.toLocaleString()}</b> phantom</span>`) +
187
+ `<span>each ${chunkKB} KB chunk checked in <b>${per.toFixed(2)} ms</b> on the ${unit}</span>` +
188
+ `<span>scrolling <b>${kbs < 1 ? Math.round(kbs * 1000) + " B/s" : kbs < 1000 ? kbs.toFixed(0) + " KB/s" : (kbs / 1000).toFixed(1) + " MB/s"}</b></span>`;
189
+ }
190
+
191
+ let last = performance.now();
192
+ function frame(t) {
193
+ const dt = Math.min(0.1, (t - last) / 1000); last = t;
194
+ const vh = vp.clientHeight;
195
+ if (runLeft > 0 && !paused) {
196
+ runLeft -= dt;
197
+ $("runbar").style.width = (100 * (1 - Math.max(0, runLeft) / RUN_S)) + "%";
198
+ $("play").textContent = "0:" + String(Math.ceil(Math.max(0, runLeft))).padStart(2, "0");
199
+ if (runLeft <= 0) finishRun();
200
+ }
201
+ if (!paused) {
202
+ const room = wall.scrollHeight - y - vh;
203
+ const step = Math.min(speed * dt, Math.max(0, room)); // never scroll past what has been checked
204
+ y += step; px += step;
205
+ }
206
+ // drop chunks that have scrolled off the top
207
+ while (wall.firstChild && wall.firstChild.offsetHeight < y - vh * 0.2) {
208
+ y -= wall.firstChild.offsetHeight;
209
+ wall.removeChild(wall.firstChild);
210
+ }
211
+ wall.style.transform = `translate3d(0, ${-y}px, 0)`;
212
+ // citations that crossed the reading line join the queue, in reading order
213
+ const line = vp.getBoundingClientRect().top + vh * 0.55;
214
+ let moved = 0;
215
+ while (pending.length && moved < 40) {
216
+ const m = pending[0];
217
+ if (!m.isConnected) { pending.shift(); continue; }
218
+ if (m.getBoundingClientRect().top > line) break;
219
+ pending.shift(); enqueue(m); moved++;
220
+ }
221
+ if (!inflight && wall.scrollHeight - y < vh * 3 + speed * 1.5) fetchMore(gen); // keep ~1.5 s of text ahead
222
+ requestAnimationFrame(frame);
223
+ }
224
+
225
+ function enqueue(m) {
226
+ const st = m.className.split(" ")[0], why = m.dataset.why;
227
+ const planted = m.classList.contains("planted"), missed = m.classList.contains("missed");
228
+ m.classList.add("hit");
229
+ const row = document.createElement("div");
230
+ row.className = `q ${missed ? "phantom missedrow" : st} new`;
231
+ const badge = src === "planted" && st === "phantom" ? `<span class="b">${planted ? "planted" : "in source"}</span>` : missed ? `<span class="b">missed</span>` : "";
232
+ row.innerHTML = `<i></i><span class="c">${esc(m.textContent)}${badge}</span>` + (why && st !== "ok" ? `<span class="w">${esc(why)}</span>` : "");
233
+ const L = $("lights");
234
+ L.prepend(row);
235
+ while (L.children.length > 80) L.lastChild.remove();
236
+ $("n-lit").textContent = (++lit).toLocaleString();
237
+ if (st === "phantom" || missed) {
238
+ const R = $("reds");
239
+ if (!reds) R.innerHTML = "";
240
+ R.prepend(row.cloneNode(true));
241
+ while (R.children.length > 40) R.lastChild.remove();
242
+ $("n-red").textContent = (++reds).toLocaleString();
243
+ }
244
+ }
245
+
246
+ async function start(newSrc) {
247
+ src = newSrc; gen += 1; y = 0; wall.innerHTML = "";
248
+ pending = []; lit = 0; reds = 0;
249
+ $("lights").innerHTML = ""; $("reds").innerHTML = `<div class="empty">None yet.</div>`;
250
+ $("n-lit").textContent = "0"; $("n-red").textContent = "0";
251
+ tot = { bytes: 0, cites: 0, flagged: 0, chunks: 0, planted: 0, caught: 0, missed: 0 }; recentMs = [];
252
+ document.querySelectorAll("[data-src]").forEach(b => b.classList.toggle("on", b.dataset.src === src));
253
+ next = (await fetch(`/wall/random?src=${src}`).then(r => r.json())).off;
254
+ inflight = false;
255
+ }
256
+
257
+ document.querySelectorAll("[data-src]").forEach(b => b.onclick = () => start(b.dataset.src));
258
+ document.querySelectorAll("[data-speed]").forEach(b => b.onclick = () => {
259
+ speed = +b.dataset.speed;
260
+ document.querySelectorAll("[data-speed]").forEach(x => x.classList.toggle("on", x === b));
261
+ });
262
+ function syncPause() {
263
+ $("pause").textContent = paused ? "▶ Resume" : "❚❚ Pause";
264
+ $("pause").classList.toggle("on", paused);
265
+ $("paused").style.display = paused && $("summary").style.display !== "block" ? "block" : "none";
266
+ }
267
+ const toggle = () => {
268
+ if ($("summary").style.display === "block") { $("summary").style.display = "none"; }
269
+ paused = !paused;
270
+ syncPause();
271
+ };
272
+
273
+ function finishRun() {
274
+ runLeft = 0; paused = true;
275
+ $("play").classList.remove("on"); $("play").textContent = "↻ 30 s";
276
+ const planted = src === "planted";
277
+ const kb = tot.bytes / 1000;
278
+ $("summary").innerHTML =
279
+ `<div class="t">${RUN_S} seconds · ${{ planted: "opinions + planted fakes", cold: "court opinions", lepc: "LePhantomCite briefs" }[src]} · ${document.querySelector("[data-speed].on").textContent}</div>` +
280
+ `<div class="big">${tot.cites.toLocaleString()} citations checked</div>` +
281
+ `<div class="row"><b>${kb < 1000 ? kb.toFixed(0) + " KB" : (kb / 1000).toFixed(2) + " MB"}</b> of text read · ` +
282
+ `each chunk checked in <b>${$("stats").querySelector("span:nth-last-child(2) b")?.textContent || "–"}</b> on the Neural Engine</div>` +
283
+ `<div class="row">` + (planted
284
+ ? `<b>${tot.planted}</b> fakes planted · <b class="bad">${tot.caught}</b> caught · <b>${tot.missed}</b> missed · <b>${tot.flagged - tot.caught}</b> found in the source`
285
+ : `<b class="bad">${tot.flagged}</b> phantom citations flagged`) + `</div>` +
286
+ `<div class="hint">click to keep reading</div>`;
287
+ $("summary").style.display = "block";
288
+ syncPause();
289
+ }
290
+
291
+ $("play").onclick = async (e) => {
292
+ e.stopPropagation();
293
+ $("summary").style.display = "none"; $("paused").style.display = "none";
294
+ await start(src);
295
+ paused = false; runLeft = RUN_S; syncPause();
296
+ $("play").classList.add("on"); $("runbar").style.width = "0";
297
+ };
298
+ vp.onclick = toggle;
299
+ $("pause").onclick = (e) => { e.stopPropagation(); toggle(); };
300
+ document.addEventListener("keydown", (e) => { if (e.code === "Space") { e.preventDefault(); toggle(); } });
301
+
302
+ const q = new URLSearchParams(location.search);
303
+ if (q.get("speed")) { const b = document.querySelector(`[data-speed="${q.get("speed")}"]`); if (b) b.click(); }
304
+ start(q.get("src") || "planted").then(() => {
305
+ requestAnimationFrame(frame);
306
+ if (q.get("play")) $("play").click();
307
+ });
308
+ setInterval(stats, 250);
309
+ </script>
310
+ </body>
311
+ </html>
models/CiteNet_b1.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ec3a0cfb04508460da297d90e6d5ac3f4fda5f924039e6209905c8836d12ef6b
3
+ size 15201
models/CiteNet_b1.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f6b5d99f5e879552c83f6163c09b705de858c8ef3c4910d0dced667d2042eef
3
+ size 795972
models/CiteNet_b1.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "B756F141-B556-40C4-B761-26475A7395DF": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Weights",
7
+ "name": "weights",
8
+ "path": "com.apple.CoreML/weights"
9
+ },
10
+ "BB7691E4-DCAA-452B-B171-DCCC5C95FAB9": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Specification",
13
+ "name": "model.mlmodel",
14
+ "path": "com.apple.CoreML/model.mlmodel"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "BB7691E4-DCAA-452B-B171-DCCC5C95FAB9"
18
+ }
models/CiteNet_b32.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0005b22c1df3adfe2c105594c815affa760d9cc38894b1aa6574c857341eb9f4
3
+ size 15202
models/CiteNet_b32.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f6b5d99f5e879552c83f6163c09b705de858c8ef3c4910d0dced667d2042eef
3
+ size 795972
models/CiteNet_b32.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "4B03BD52-4F51-47DE-95C8-E345C5F7F1BD": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Weights",
7
+ "name": "weights",
8
+ "path": "com.apple.CoreML/weights"
9
+ },
10
+ "4EF2FA4D-6F4A-4DCF-A2A1-B32C86D83C3D": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Specification",
13
+ "name": "model.mlmodel",
14
+ "path": "com.apple.CoreML/model.mlmodel"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "4EF2FA4D-6F4A-4DCF-A2A1-B32C86D83C3D"
18
+ }
models/CiteNet_b512.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:67b85f25cbf21b2433a14f0631b77bf8c18df9f647003a2d209b93dcfe7e825d
3
+ size 15244
models/CiteNet_b512.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f6b5d99f5e879552c83f6163c09b705de858c8ef3c4910d0dced667d2042eef
3
+ size 795972
models/CiteNet_b512.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "7CD9686B-F9F7-4E15-92F2-4388F2E4A50A": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Specification",
7
+ "name": "model.mlmodel",
8
+ "path": "com.apple.CoreML/model.mlmodel"
9
+ },
10
+ "960033A0-1526-47ED-8F07-3A7AA6BAF996": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Weights",
13
+ "name": "weights",
14
+ "path": "com.apple.CoreML/weights"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "7CD9686B-F9F7-4E15-92F2-4388F2E4A50A"
18
+ }
models/CiteNet_lean_b512.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ebba1df193dae2767ce365708488b28cb5609a30227cc9d4b6d70979b1b8d6ab
3
+ size 14674
models/CiteNet_lean_b512.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f6b5d99f5e879552c83f6163c09b705de858c8ef3c4910d0dced667d2042eef
3
+ size 795972
models/CiteNet_lean_b512.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "F3AF92E8-6810-444E-8DCD-DCE24722DD8D": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Weights",
7
+ "name": "weights",
8
+ "path": "com.apple.CoreML/weights"
9
+ },
10
+ "FE87E531-E500-4514-AC4C-357B5176D8EF": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Specification",
13
+ "name": "model.mlmodel",
14
+ "path": "com.apple.CoreML/model.mlmodel"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "FE87E531-E500-4514-AC4C-357B5176D8EF"
18
+ }
models/CiteNet_lean_b8192.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d4a65ada73a068b4c8e1fa5f5a13b2bd11f33425f73514f4fab5aa010bd4c946
3
+ size 14674
models/CiteNet_lean_b8192.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9f6b5d99f5e879552c83f6163c09b705de858c8ef3c4910d0dced667d2042eef
3
+ size 795972
models/CiteNet_lean_b8192.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "54300A80-AA23-4C07-9E54-8525A1FA637F": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Specification",
7
+ "name": "model.mlmodel",
8
+ "path": "com.apple.CoreML/model.mlmodel"
9
+ },
10
+ "F475A9B2-83E4-48F8-9953-2EB8FAFACC89": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Weights",
13
+ "name": "weights",
14
+ "path": "com.apple.CoreML/weights"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "54300A80-AA23-4C07-9E54-8525A1FA637F"
18
+ }
models/meta.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"features": ["rep:u.s.", "rep:us_#", "rep:us_at_", "rep:s.ct.", "rep:s ct", "rep:dall.", "rep:cranch", "rep:wheat.", "rep:pet.", "rep:how.", "rep:black", "rep:wall.", "rep:l.ed.", "rep:l.ed.2d", "rep:f.", "rep:f.2d", "rep:f.3d", "rep:f.4th", "rep:f.app'x", "rep:f.appx", "rep:fed.appx.", "rep:fed.appx", "rep:fed.app'x", "rep:fed.app.", "rep:f.supp.", "rep:f.supp_#", "rep:f.supp_at_", "rep:f.supp.2d", "rep:f.supp.3d", "rep:f.supp 2d", "rep:f.supp 3d", "rep:f.r.d.", "rep:b.r.", "rep:fed.cl.", "rep:ct.cl.", "rep:cl.ct.", "rep:f.cas.", "rep:ct.int'l", "rep:m.j.", "rep:a.", "rep:a.2d", "rep:a.3d", "rep:n.e.", "rep:n.e.2d", "rep:n.e.3d", "rep:n.w.", "rep:n.w.2d", "rep:n.w.3d", "rep:p.", "rep:p", "rep:p.2d", "rep:p.3d", "rep:s.e.", "rep:s.e.2d", "rep:s.e.3d", "rep:s.w.", "rep:s.w.2d", "rep:s.w.3d", "rep:so.", "rep:so.2d", "rep:so.3d", "rep:cal.rptr.", "rep:cal.rptr.2d", "rep:cal.rptr.3d", "rep:cal.", "rep:cal.2d", "rep:cal.3d", "rep:cal.4th", "rep:cal.5th", "rep:cal.app.", "rep:cal.app.2d", "rep:cal.app.3d", "rep:cal.app.4th", "rep:cal.app.5th", "rep:n.y.s.", "rep:n.y.s.2d", "rep:n.y.s.3d", "rep:n.y.", "rep:n.y.2d", "rep:n.y.3d", "rep:a.d.", "rep:a.d.2d", "rep:a.d.3d", "rep:misc.2d", "rep:misc.3d", "rep:mass.", "rep:mass.app.ct.", "rep:mass.app.", "rep:ill.", "rep:ill.2d", "rep:ill.app.", "rep:ill.app.2d", "rep:ill.app.3d", "rep:ill.dec.", "rep:ind.", "rep:ind.app.", "rep:okla.", "rep:va.", "rep:va.app.", "rep:va.cir.", "rep:pa.", "rep:pa.super.", "rep:pa.commw.", "rep:n.j.", "rep:n.j.super.", "rep:wash.", "rep:wash.2d", "rep:wash.app.", "rep:wash.app.2d", "rep:ohio st.3d", "rep:ohio app.3d", "rep:md.", "rep:md.app.", "rep:n.c.", "rep:n.c.app.", "rep:ga.", "rep:ga.app.", "rep:kan.", "rep:kan.app.", "rep:kan.app.2d", "rep:mich.", "rep:mich.app.", "rep:wis.2d", "rep:conn.", "rep:conn.app.", "rep:tex.", "rep:s.c.", "rep:r.i.", "rep:colo.", "rep:ariz.", "rep:or.", "rep:or.app.", "rep:wn.", "rep:wn.2d", "rep:wn.app.", "rep:wn.app.2d", "rep:neb.", "rep:neb.app.", "rep:n.m.", "rep:nev.", "rep:minn.", "rep:ark.", "rep:ark.app.", "rep:idaho", "rep:mont.", "rep:ohio st.", "rep:ohio st.2d", "rep:ohio app.", "rep:ohio app.2d", "rep:ala.", "rep:ala.app.", "rep:la.", "rep:iowa", "rep:miss.", "rep:wis.", "rep:w.va.", "rep:vt.", "rep:mo.", "rep:mo.app.", "rep:n.h.", "rep:ky.", "rep:tenn.", "rep:tenn.app.", "rep:del.", "rep:me.", "rep:haw.", "rep:wyo.", "rep:utah", "rep:utah 2d", "rep:n.d.", "rep:s.d.", "rep:alaska", "rep:ny", "rep:ny2d", "rep:ny3d", "rep:n y 2d", "rep:ad2d", "rep:ad3d", "rep:a d 2d", "rep:app.div.", "rep:app div", "rep:app.div.2d", "rep:misc.", "rep:misc 2d", "rep:misc 3d", "rep:misc.rep.", "rep:nys2d", "rep:nys3d", "rep:f2d", "rep:f3d", "rep:f4th", "rep:a2d", "rep:a3d", "rep:ne2d", "rep:ne3d", "rep:nw2d", "rep:p2d", "rep:p3d", "rep:se2d", "rep:sw2d", "rep:sw3d", "rep:so2d", "rep:so3d", "rep:wl", "other:fed.reg.", "other:fr", "other:u.s.c.", "other:c.f.r.", "other:stat.", "other:ferc", "other:t.c.", "other:c.m.r.", "other:cmr", "other:vet.app.", "other:b.t.a.", "other:fed.cir.", "other:c.c.p.a.", "other:o.s.", "other:ok neutral", "other:ok civ app", "other:op.o.l.c.", "other:op.att'y", "other:a.l.r.", "other:ilcs", "other:nlrb", "other:uscma", "other:cong.rec.", "other:l.r.a.", "other:r.s.", "other:burns", "other:ops.cal.atty", "other:d.p.r.", "other:dpr", "other:j.t.s.", "other:mal", "other:ut neutral", "other:ut app", "other:nd neutral", "other:ny slip op", "other:johns.", "other:wend.", "other:cow.", "other:barb.", "other:hun", "other:paige", "other:pick.", "other:cush.", "other:met.", "other:gray", "other:allen", "other:binn.", "other:watts", "series:ord4", "series:ord4s", "series:ord4c", "series:ord4sc", "series:ord5", "series:ord5s", "series:ord5c", "series:ord5sc", "series:ord6+", "series:ord6+s", "series:ord6+c", "series:ord6+sc", "other:lexis", "other:i&n", "other:law review", "other:law journal", "other:u.s.app.d.c.", "court:circuit", "court:circuit_dc", "court:circuit_fed", "court:district_x", "court:district_d", "court:bankruptcy", "court:bap", "court:year_only", "court:expl_quoting", "court:expl_citing", "court:expl_internal", "court:expl_alteration", "court:expl_emphasis", "court:expl_quotation", "court:expl_par1", "court:expl_par2", "court:expl_par3", "court:expl_affirmed", "court:expl_affd", "court:expl_revd", "court:expl_cert", "court:expl_vacated", "court:expl_overruled", "court:expl_abrogated", "court:expl_superseded", "court:expl_modified", "court:state_conn", "court:state_del", "court:state_me", "court:state_md", "court:state_n.h", "court:state_n.j", "court:state_pa", "court:state_r.i", "court:state_vt", "court:state_d.c", "court:state_ill", "court:state_ind", "court:state_mass", "court:state_n.y", "court:state_ohio", "court:state_iowa", "court:state_mich", "court:state_minn", "court:state_neb", "court:state_wis", "court:state_alaska", "court:state_ariz", "court:state_cal", "court:state_colo", "court:state_haw", "court:state_idaho", "court:state_kan", "court:state_mont", "court:state_nev", "court:state_n.m", "court:state_okla", "court:state_or", "court:state_utah", "court:state_wash", "court:state_wyo", "court:state_ga", "court:state_n.c", "court:state_s.c", "court:state_va", "court:state_w.va", "court:state_ark", "court:state_ky", "court:state_mo", "court:state_tenn", "court:state_tex", "court:state_ala", "court:state_fla", "court:state_la", "court:state_miss", "court:dc_app", "court:dc_mun", "num:vol1", "num:vol2", "num:vol3", "num:vol4", "num:year", "num:year20", "num:vol4y", "gate:vol1", "gate:vol2", "gate:vol3", "gate:vol4", "gate:year", "gate:year20", "gate:vol4y"], "concepts": ["class:BANKR", "class:BANKR^hi", "class:FEDAPP", "class:FEDAPP^hi", "class:FEDAPP_OLD", "class:FEDAPP_OLD^hi", "class:FEDDIST", "class:FEDDIST^hi", "class:FEDSPEC", "class:FEDSPEC^hi", "class:REGION", "class:REGION^hi", "class:SCOTUS", "class:SCOTUS^hi", "class:STATE", "class:STATE^hi", "class:WL", "class:WL^hi", "region:A", "region:A^hi", "region:NE", "region:NE^hi", "region:NW", "region:NW^hi", "region:P", "region:P^hi", "region:SE", "region:SE^hi", "region:SO", "region:SO^hi", "region:SW", "region:SW^hi", "home:ala", "home:ala^hi", "home:alaska", "home:alaska^hi", "home:ariz", "home:ariz^hi", "home:ark", "home:ark^hi", "home:cal", "home:cal^hi", "home:colo", "home:colo^hi", "home:conn", "home:conn^hi", "home:del", "home:del^hi", "home:ga", "home:ga^hi", "home:haw", "home:haw^hi", "home:idaho", "home:idaho^hi", "home:ill", "home:ill^hi", "home:ind", "home:ind^hi", "home:iowa", "home:iowa^hi", "home:kan", "home:kan^hi", "home:ky", "home:ky^hi", "home:la", "home:la^hi", "home:mass", "home:mass^hi", "home:md", "home:md^hi", "home:me", "home:me^hi", "home:mich", "home:mich^hi", "home:minn", "home:minn^hi", "home:miss", "home:miss^hi", "home:mo", "home:mo^hi", "home:mont", "home:mont^hi", "home:n.c", "home:n.c^hi", "home:n.d", "home:n.d^hi", "home:n.h", "home:n.h^hi", "home:n.j", "home:n.j^hi", "home:n.m", "home:n.m^hi", "home:n.y", "home:n.y^hi", "home:neb", "home:neb^hi", "home:nev", "home:nev^hi", "home:ohio", "home:ohio^hi", "home:okla", "home:okla^hi", "home:or", "home:or^hi", "home:pa", "home:pa^hi", "home:r.i", "home:r.i^hi", "home:s.c", "home:s.c^hi", "home:s.d", "home:s.d^hi", "home:tenn", "home:tenn^hi", "home:tex", "home:tex^hi", "home:utah", "home:utah^hi", "home:va", "home:va^hi", "home:vt", "home:vt^hi", "home:w.va", "home:w.va^hi", "home:wash", "home:wash^hi", "home:wis", "home:wis^hi", "home:wyo", "home:wyo^hi", "is:us", "is:us^hi", "is:sct", "is:sct^hi", "is:led", "is:led^hi", "is:led2", "is:led2^hi", "is:f1", "is:f1^hi", "is:f2", "is:f2^hi", "is:f3", "is:f3^hi", "is:f4", "is:f4^hi", "is:fs1", "is:fs1^hi", "is:fs2", "is:fs2^hi", "is:fs3", "is:fs3^hi", "is:fappx", "is:fappx^hi", "is:br", "is:br^hi", "is:frd", "is:frd^hi", "is:a2", "is:a2^hi", "is:a3", "is:a3^hi", "is:ne2", "is:ne2^hi", "is:ne3", "is:ne3^hi", "is:nw2", "is:nw2^hi", "is:p2", "is:p2^hi", "is:p3", "is:p3^hi", "is:se2", "is:se2^hi", "is:sw2", "is:sw2^hi", "is:sw3", "is:sw3^hi", "is:so2", "is:so2^hi", "is:so3", "is:so3^hi", "is:cr1", "is:cr1^hi", "is:cr2", "is:cr2^hi", "is:cr3", "is:cr3^hi", "is:nys2", "is:nys2^hi", "is:nys3", "is:nys3^hi", "is:wl", "is:wl^hi", "is:lexis", "is:lexis^hi", "phantom:4th", "phantom:5th", "phantom:6th+", "known_source", "expl", "court:circuit", "court:district", "court:bankruptcy", "state:conn", "state:del", "state:me", "state:md", "state:n.h", "state:n.j", "state:pa", "state:r.i", "state:vt", "state:d.c", "state:ill", "state:ind", "state:mass", "state:n.y", "state:ohio", "state:iowa", "state:mich", "state:minn", "state:neb", "state:wis", "state:alaska", "state:ariz", "state:cal", "state:colo", "state:haw", "state:idaho", "state:kan", "state:mont", "state:nev", "state:n.m", "state:okla", "state:or", "state:utah", "state:wash", "state:wyo", "state:ga", "state:n.c", "state:s.c", "state:va", "state:w.va", "state:ark", "state:ky", "state:mo", "state:tenn", "state:tex", "state:ala", "state:fla", "state:la", "state:miss", "court:year_only", "volume", "has_volume", "year", "year_hi", "has_year", "has_year^hi", "vol4y", "vol4y_hi", "future", "volume3", "has_vol4", "vol>0", "vol>2", "vol>4", "vol>6", "vol>8", "vol>10", "vol>15", "vol>20", "vol>30", "vol>40", "vol>50", "vol>60", "vol>75", "vol>90", "vol>100", "vol>125", "vol>150", "vol>175", "vol>200", "vol>250", "vol>300", "vol>350", "vol>400", "vol>450", "vol>500", "vol>550", "vol>600", "vol>650", "vol>700", "vol>750", "vol>800", "vol>850", "vol>900", "vol>950"], "rules": ["mm:SCOTUS", "mm:FEDAPP", "mm:FEDAPP_OLD", "mm:FEDDIST", "mm:BANKR", "mm:REGION_fed", "mm:STATE_fed", "mm:region_A", "mm:region_NE", "mm:region_NW", "mm:region_P", "mm:region_SE", "mm:region_SO", "mm:region_SW", "mm:home_ala", "mm:home_alaska", "mm:home_ariz", "mm:home_ark", "mm:home_cal", "mm:home_colo", "mm:home_conn", "mm:home_del", "mm:home_ga", "mm:home_haw", "mm:home_idaho", "mm:home_ill", "mm:home_ind", "mm:home_iowa", "mm:home_kan", "mm:home_ky", "mm:home_la", "mm:home_mass", "mm:home_md", "mm:home_me", "mm:home_mich", "mm:home_minn", "mm:home_miss", "mm:home_mo", "mm:home_mont", "mm:home_n.c", "mm:home_n.d", "mm:home_n.h", "mm:home_n.j", "mm:home_n.m", "mm:home_n.y", "mm:home_neb", "mm:home_nev", "mm:home_ohio", "mm:home_okla", "mm:home_or", "mm:home_pa", "mm:home_r.i", "mm:home_s.c", "mm:home_s.d", "mm:home_tenn", "mm:home_tex", "mm:home_utah", "mm:home_va", "mm:home_vt", "mm:home_w.va", "mm:home_wash", "mm:home_wis", "mm:home_wyo", "late:us", "early:us", "over:us", "over4:us", "late:sct", "early:sct", "over:sct", "over4:sct", "late:led", "early:led", "over:led", "over4:led", "late:led2", "early:led2", "over:led2", "over4:led2", "late:f1", "early:f1", "over:f1", "over4:f1", "late:f2", "early:f2", "over:f2", "over4:f2", "late:f3", "early:f3", "over:f3", "over4:f3", "late:f4", "early:f4", "over:f4", "over4:f4", "late:fs1", "early:fs1", "over:fs1", "over4:fs1", "late:fs2", "early:fs2", "over:fs2", "over4:fs2", "late:fs3", "early:fs3", "over:fs3", "over4:fs3", "late:fappx", "early:fappx", "over:fappx", "over4:fappx", "late:br", "early:br", "over:br", "over4:br", "late:frd", "early:frd", "over:frd", "over4:frd", "late:a2", "early:a2", "over:a2", "over4:a2", "late:a3", "early:a3", "over:a3", "over4:a3", "late:ne2", "early:ne2", "over:ne2", "over4:ne2", "late:ne3", "early:ne3", "over:ne3", "over4:ne3", "late:nw2", "early:nw2", "over:nw2", "over4:nw2", "late:p2", "early:p2", "over:p2", "over4:p2", "late:p3", "early:p3", "over:p3", "over4:p3", "late:se2", "early:se2", "over:se2", "over4:se2", "late:sw2", "early:sw2", "over:sw2", "over4:sw2", "late:sw3", "early:sw3", "over:sw3", "over4:sw3", "late:so2", "early:so2", "over:so2", "over4:so2", "late:so3", "early:so3", "over:so3", "over4:so3", "late:cr1", "early:cr1", "over:cr1", "over4:cr1", "late:cr2", "early:cr2", "over:cr2", "over4:cr2", "late:cr3", "early:cr3", "over:cr3", "over4:cr3", "late:nys2", "early:nys2", "over:nys2", "over4:nys2", "late:nys3", "early:nys3", "over:nys3", "over4:nys3", "late:wl", "early:wl", "late:lexis", "early:lexis", "r:phantom:4th", "r:phantom:5th", "r:phantom:6th+", "r:future", "r:unknown"], "evidence": ["phantom_series", "court_mismatch", "state_mismatch", "volume_year", "volume_missing", "future_year", "unknown_reporter"], "groups": {"court_mismatch": ["mm:SCOTUS", "mm:FEDAPP", "mm:FEDAPP_OLD", "mm:FEDDIST", "mm:BANKR", "mm:REGION_fed", "mm:STATE_fed"], "state_mismatch": ["mm:region_A", "mm:region_NE", "mm:region_NW", "mm:region_P", "mm:region_SE", "mm:region_SO", "mm:region_SW", "mm:home_ala", "mm:home_alaska", "mm:home_ariz", "mm:home_ark", "mm:home_cal", "mm:home_colo", "mm:home_conn", "mm:home_del", "mm:home_ga", "mm:home_haw", "mm:home_idaho", "mm:home_ill", "mm:home_ind", "mm:home_iowa", "mm:home_kan", "mm:home_ky", "mm:home_la", "mm:home_mass", "mm:home_md", "mm:home_me", "mm:home_mich", "mm:home_minn", "mm:home_miss", "mm:home_mo", "mm:home_mont", "mm:home_n.c", "mm:home_n.d", "mm:home_n.h", "mm:home_n.j", "mm:home_n.m", "mm:home_n.y", "mm:home_neb", "mm:home_nev", "mm:home_ohio", "mm:home_okla", "mm:home_or", "mm:home_pa", "mm:home_r.i", "mm:home_s.c", "mm:home_s.d", "mm:home_tenn", "mm:home_tex", "mm:home_utah", "mm:home_va", "mm:home_vt", "mm:home_w.va", "mm:home_wash", "mm:home_wis", "mm:home_wyo"], "volume_year": ["late:us", "early:us", "late:sct", "early:sct", "late:led", "early:led", "late:led2", "early:led2", "late:f1", "early:f1", "late:f2", "early:f2", "late:f3", "early:f3", "late:f4", "early:f4", "late:fs1", "early:fs1", "late:fs2", "early:fs2", "late:fs3", "early:fs3", "late:fappx", "early:fappx", "late:br", "early:br", "late:frd", "early:frd", "late:a2", "early:a2", "late:a3", "early:a3", "late:ne2", "early:ne2", "late:ne3", "early:ne3", "late:nw2", "early:nw2", "late:p2", "early:p2", "late:p3", "early:p3", "late:se2", "early:se2", "late:sw2", "early:sw2", "late:sw3", "early:sw3", "late:so2", "early:so2", "late:so3", "early:so3", "late:cr1", "early:cr1", "late:cr2", "early:cr2", "late:cr3", "early:cr3", "late:nys2", "early:nys2", "late:nys3", "early:nys3", "late:wl", "early:wl", "late:lexis", "early:lexis"], "volume_missing": ["over:us", "over4:us", "over:sct", "over4:sct", "over:led", "over4:led", "over:led2", "over4:led2", "over:f1", "over4:f1", "over:f2", "over4:f2", "over:f3", "over4:f3", "over:f4", "over4:f4", "over:fs1", "over4:fs1", "over:fs2", "over4:fs2", "over:fs3", "over4:fs3", "over:fappx", "over4:fappx", "over:br", "over4:br", "over:frd", "over4:frd", "over:a2", "over4:a2", "over:a3", "over4:a3", "over:ne2", "over4:ne2", "over:ne3", "over4:ne3", "over:nw2", "over4:nw2", "over:p2", "over4:p2", "over:p3", "over4:p3", "over:se2", "over4:se2", "over:sw2", "over4:sw2", "over:sw3", "over4:sw3", "over:so2", "over4:so2", "over:so3", "over4:so3", "over:cr1", "over4:cr1", "over:cr2", "over4:cr2", "over:cr3", "over4:cr3", "over:nys2", "over4:nys2", "over:nys3", "over4:nys3"], "phantom_series": ["r:phantom:4th", "r:phantom:5th", "r:phantom:6th+"], "future_year": ["r:future"], "unknown_reporter": ["r:unknown"]}, "anchored": ["rep:u.s.", "rep:us_#", "rep:us_at_", "rep:s.ct.", "rep:s ct", "rep:dall.", "rep:cranch", "rep:wheat.", "rep:pet.", "rep:how.", "rep:black", "rep:wall.", "rep:l.ed.", "rep:l.ed.2d", "rep:f.", "rep:f.2d", "rep:f.3d", "rep:f.4th", "rep:f.app'x", "rep:f.appx", "rep:fed.appx.", "rep:fed.appx", "rep:fed.app'x", "rep:fed.app.", "rep:f.supp.", "rep:f.supp_#", "rep:f.supp_at_", "rep:f.supp.2d", "rep:f.supp.3d", "rep:f.supp 2d", "rep:f.supp 3d", "rep:f.r.d.", "rep:b.r.", "rep:fed.cl.", "rep:ct.cl.", "rep:cl.ct.", "rep:f.cas.", "rep:ct.int'l", "rep:m.j.", "rep:a.", "rep:a.2d", "rep:a.3d", "rep:n.e.", "rep:n.e.2d", "rep:n.e.3d", "rep:n.w.", "rep:n.w.2d", "rep:n.w.3d", "rep:p.", "rep:p", "rep:p.2d", "rep:p.3d", "rep:s.e.", "rep:s.e.2d", "rep:s.e.3d", "rep:s.w.", "rep:s.w.2d", "rep:s.w.3d", "rep:so.", "rep:so.2d", "rep:so.3d", "rep:cal.rptr.", "rep:cal.rptr.2d", "rep:cal.rptr.3d", "rep:cal.", "rep:cal.2d", "rep:cal.3d", "rep:cal.4th", "rep:cal.5th", "rep:cal.app.", "rep:cal.app.2d", "rep:cal.app.3d", "rep:cal.app.4th", "rep:cal.app.5th", "rep:n.y.s.", "rep:n.y.s.2d", "rep:n.y.s.3d", "rep:n.y.", "rep:n.y.2d", "rep:n.y.3d", "rep:a.d.", "rep:a.d.2d", "rep:a.d.3d", "rep:misc.2d", "rep:misc.3d", "rep:mass.", "rep:mass.app.ct.", "rep:mass.app.", "rep:ill.", "rep:ill.2d", "rep:ill.app.", "rep:ill.app.2d", "rep:ill.app.3d", "rep:ill.dec.", "rep:ind.", "rep:ind.app.", "rep:okla.", "rep:va.", "rep:va.app.", "rep:va.cir.", "rep:pa.", "rep:pa.super.", "rep:pa.commw.", "rep:n.j.", "rep:n.j.super.", "rep:wash.", "rep:wash.2d", "rep:wash.app.", "rep:wash.app.2d", "rep:ohio st.3d", "rep:ohio app.3d", "rep:md.", "rep:md.app.", "rep:n.c.", "rep:n.c.app.", "rep:ga.", "rep:ga.app.", "rep:kan.", "rep:kan.app.", "rep:kan.app.2d", "rep:mich.", "rep:mich.app.", "rep:wis.2d", "rep:conn.", "rep:conn.app.", "rep:tex.", "rep:s.c.", "rep:r.i.", "rep:colo.", "rep:ariz.", "rep:or.", "rep:or.app.", "rep:wn.", "rep:wn.2d", "rep:wn.app.", "rep:wn.app.2d", "rep:neb.", "rep:neb.app.", "rep:n.m.", "rep:nev.", "rep:minn.", "rep:ark.", "rep:ark.app.", "rep:idaho", "rep:mont.", "rep:ohio st.", "rep:ohio st.2d", "rep:ohio app.", "rep:ohio app.2d", "rep:ala.", "rep:ala.app.", "rep:la.", "rep:iowa", "rep:miss.", "rep:wis.", "rep:w.va.", "rep:vt.", "rep:mo.", "rep:mo.app.", "rep:n.h.", "rep:ky.", "rep:tenn.", "rep:tenn.app.", "rep:del.", "rep:me.", "rep:haw.", "rep:wyo.", "rep:utah", "rep:utah 2d", "rep:n.d.", "rep:s.d.", "rep:alaska", "rep:ny", "rep:ny2d", "rep:ny3d", "rep:n y 2d", "rep:ad2d", "rep:ad3d", "rep:a d 2d", "rep:app.div.", "rep:app div", "rep:app.div.2d", "rep:misc.", "rep:misc 2d", "rep:misc 3d", "rep:misc.rep.", "rep:nys2d", "rep:nys3d", "rep:f2d", "rep:f3d", "rep:f4th", "rep:a2d", "rep:a3d", "rep:ne2d", "rep:ne3d", "rep:nw2d", "rep:p2d", "rep:p3d", "rep:se2d", "rep:sw2d", "rep:sw3d", "rep:so2d", "rep:so3d", "rep:wl", "other:fed.reg.", "other:fr", "other:u.s.c.", "other:c.f.r.", "other:stat.", "other:ferc", "other:t.c.", "other:c.m.r.", "other:cmr", "other:vet.app.", "other:b.t.a.", "other:fed.cir.", "other:c.c.p.a.", "other:o.s.", "other:ok neutral", "other:ok civ app", "other:op.o.l.c.", "other:op.att'y", "other:a.l.r.", "other:ilcs", "other:nlrb", "other:uscma", "other:cong.rec.", "other:l.r.a.", "other:r.s.", "other:burns", "other:ops.cal.atty", "other:d.p.r.", "other:dpr", "other:j.t.s.", "other:mal", "other:ut neutral", "other:ut app", "other:nd neutral", "other:ny slip op", "other:johns.", "other:wend.", "other:cow.", "other:barb.", "other:hun", "other:paige", "other:pick.", "other:cush.", "other:met.", "other:gray", "other:allen", "other:binn.", "other:watts"], "floating": ["series:ord4", "series:ord4s", "series:ord4c", "series:ord4sc", "series:ord5", "series:ord5s", "series:ord5c", "series:ord5sc", "series:ord6+", "series:ord6+s", "series:ord6+c", "series:ord6+sc", "other:lexis", "other:i&n", "other:law review", "other:law journal", "other:u.s.app.d.c."]}
models/weights.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8bad6f5b755fa19e6188f5ffe8581775157d0e01c7a96db42b7cf012058ef2d6
3
+ size 19448
results/aux_train_v1.5.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ params 397,250 (nonzero 13,871)
2
+ Counter({np.str_('genuine'): 2340, np.str_('other'): 634, np.str_('phantom'): 119})
3
+ phantom recall 119/119 = 1.000
4
+ genuine false-flag 2/2340 = 0.0009
5
+ precision 0.983
6
+ other-hallucination cites flagged 3/634
7
+ cites where >1 reporter class fired: 0 []
8
+
9
+ -- missed phantoms
10
+
11
+ -- false flags
12
+ 0.971 '588 F.3d 1318, 1328 (11th Cir. 2002);' -> '588 f.3d 1318, 1328 (11th cir. 2002)' [volume_year=3.00]
13
+ 0.971 '395 U.S. 411, 421 (1959);' -> '395 u.s. 411, 421 (1959)' [volume_year=3.00]
14
+
15
+ -- genuine but amber (0.1-0.5): 3
16
+ 0.119 '23 p.r.offic.trans. 393' [unknown_reporter=1.00]
17
+ 0.119 '773 f.3d. 138' [unknown_reporter=1.00]
18
+ 0.119 '3 va.law rep. 1177 (va.ct.app. 1986)' [unknown_reporter=1.00]
results/bench_swift.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ b1 cpu p50 50.1 us p99 63.6 us 19538 cites/s
2
+ b1 ane p50 49.5 us p99 62.5 us 19782 cites/s
3
+ b32 cpu p50 329.2 us p99 353.6 us 96915 cites/s
4
+ b32 ane p50 169.5 us p99 207.0 us 187674 cites/s
5
+ b32 gpu p50 330.1 us p99 381.1 us 96141 cites/s
6
+ b512 cpu p50 4004.8 us p99 4340.2 us 127204 cites/s
7
+ b512 ane p50 1213.6 us p99 1279.0 us 421373 cites/s
8
+ b512 gpu p50 643.5 us p99 781.5 us 786837 cites/s
9
+ b2048 ane p50 5598.4 us p99 5770.8 us 365059 cites/s
10
+ b2048 gpu p50 1806.5 us p99 2988.0 us 1083873 cites/s
11
+ b8192 ane p50 26298.8 us p99 27173.3 us 310711 cites/s
12
+ b8192 gpu p50 6161.5 us p99 6463.7 us 1327992 cites/s
13
+ concurrent ane b512 421k/s -> 421079 cites/s
14
+ concurrent gpu b8192 1320k/s -> 1320080 cites/s
15
+ concurrent ane b512 409k/s + gpu b8192 1297k/s -> 1706405 cites/s
16
+ concurrent ane b2048 357k/s + gpu b8192 1294k/s -> 1650628 cites/s
17
+ concurrent ane b512 217k/s + ane b512 217k/s + gpu b8192 606k/s + gpu b8192 601k/s -> 1640213 cites/s
results/eval_v1.0_blind.txt ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ params 190,664 (nonzero 7,901)
2
+ Counter({np.str_('genuine'): 983, np.str_('other'): 276, np.str_('phantom'): 31})
3
+ phantom recall 31/31 = 1.000
4
+ genuine false-flag 3/983 = 0.0031
5
+ precision 0.912
6
+ other-hallucination cites flagged 2/276
7
+
8
+ -- missed phantoms
9
+
10
+ -- false flags
11
+ 0.971 '479 F.3d 875 (03-1202),' -> '479 f.3d 875 (03-1202)' [volume_year=3.00]
12
+ 0.953 '652 A.2d 634, 636 (D.C. Cir. 1995)' -> '652 a.2d 634, 636 (d.c.cir. 1995)' [court_mismatch=1.00]
13
+ 0.953 '2013 US Dist LEXIS 14304' -> '2013 us dist lexis 14304' [volume_missing=1.00]
14
+
15
+ -- genuine but amber (0.1-0.5): 14
16
+ 0.119 '55 ind.app. 455, 464 (1914)' [unknown_reporter=1.00]
17
+ 0.119 '120 s ct 1878' [unknown_reporter=1.00]
18
+ 0.119 '957 f.2d. 655' [unknown_reporter=1.00]
19
+ 0.119 '14 how. 156, 175, 14' [unknown_reporter=1.00]
20
+ 0.119 '70 o.s. 6' [unknown_reporter=1.00]
21
+ 0.119 '2011 ok civ app 68' [unknown_reporter=1.00]
22
+ 0.119 '70 o.s. 5' [unknown_reporter=1.00]
23
+ 0.119 '1995 ok 138' [unknown_reporter=1.00]
24
+ 0.119 '189 ok 49' [unknown_reporter=1.00]
25
+ 0.119 '151 f.2d. 240' [unknown_reporter=1.00]
26
+ 0.119 '433 f.3d. 867' [unknown_reporter=1.00]
27
+ 0.119 '31 op.o.l.c. 73' [unknown_reporter=1.00]
28
+ 0.119 '70 o.s. 5' [unknown_reporter=1.00]
29
+ 0.119 '1995 ok 138' [unknown_reporter=1.00]
results/eval_v1.5.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ params 397,250 (nonzero 13,871)
2
+ Counter({np.str_('genuine'): 983, np.str_('other'): 276, np.str_('phantom'): 31})
3
+ phantom recall 31/31 = 1.000
4
+ genuine false-flag 1/983 = 0.0010
5
+ precision 0.969
6
+ other-hallucination cites flagged 2/276
7
+ cites where >1 reporter class fired: 0 []
8
+
9
+ -- missed phantoms
10
+
11
+ -- false flags
12
+ 0.953 '652 A.2d 634, 636 (D.C. Cir. 1995)' -> '652 a.2d 634, 636 (d.c.cir. 1995)' [court_mismatch=1.00]
13
+
14
+ -- genuine but amber (0.1-0.5): 3
15
+ 0.119 '957 f.2d. 655' [unknown_reporter=1.00]
16
+ 0.119 '151 f.2d. 240' [unknown_reporter=1.00]
17
+ 0.119 '433 f.3d. 867' [unknown_reporter=1.00]
results/firehose.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {"n": 3000320, "seconds": 6.9778, "per_sec": 429979, "flagged": 99632, "lanes": {"ane": {"batch": 512, "threads": 2, "cites": 3000320}}}
2
+ {"n": 3006464, "seconds": 2.1772, "per_sec": 1380882, "flagged": 107253, "lanes": {"gpu": {"batch": 8192, "threads": 2, "cites": 3006464}}}
3
+ {"n": 3000320, "seconds": 1.6592, "per_sec": 1808283, "flagged": 105279, "lanes": {"ane": {"batch": 512, "threads": 2, "cites": 714752}, "gpu": {"batch": 8192, "threads": 2, "cites": 2285568}}}
4
+ {"n": 3000320, "seconds": 1.6442, "per_sec": 1824796, "flagged": 105328, "lanes": {"ane": {"batch": 512, "threads": 2, "cites": 698368}, "gpu": {"batch": 8192, "threads": 2, "cites": 2301952}}}
results/train_baseline_v1.1.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /Users/hanweng/citenet/scripts/train_baseline.py:47: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.detach().clone() or sourceTensor.detach().clone().requires_grad_(True), rather than torch.tensor(sourceTensor).
2
+ pw = torch.tensor((1 - ytr.mean()) / ytr.mean())
3
+ hand-written (0 steps) eval phantoms 31/31 eval false flags 1/983 | brief: series fakes 2/2, other fakes 3/3, real flagged 0/7
4
+ random init, seed 0 eval phantoms 31/31 eval false flags 0/983 | brief: series fakes 1/2, other fakes 0/3, real flagged 0/7
5
+ random init, seed 1 eval phantoms 31/31 eval false flags 0/983 | brief: series fakes 1/2, other fakes 0/3, real flagged 0/7
6
+ random init, seed 2 eval phantoms 31/31 eval false flags 0/983 | brief: series fakes 1/2, other fakes 0/3, real flagged 0/7
7
+ hand init + fine-tune eval phantoms 30/31 eval false flags 118/983 | brief: series fakes 2/2, other fakes 1/3, real flagged 0/7
scripts/cold_extract.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """COLD Cases parquet shard -> one plain-text corpus for the live demo.
2
+
3
+ Writes data/cold/corpus.txt: one decision per record ("case name, court, date"
4
+ header + opinion texts), records separated by blank lines, plus corpus.json.
5
+ """
6
+ import json, sys, re, html
7
+ import pyarrow.parquet as pq
8
+
9
+ src, dst = sys.argv[1], sys.argv[2]
10
+ limit = int(sys.argv[3]) if len(sys.argv) > 3 else 10**12
11
+ pf = pq.ParquetFile(src)
12
+ print(pf.schema_arrow)
13
+ n_dec = n_bytes = 0
14
+ courts = {}
15
+ years = []
16
+ tag = re.compile(r"<[^>]+>")
17
+ with open(dst, "w", encoding="utf-8") as out:
18
+ for rg in range(pf.num_row_groups):
19
+ t = pf.read_row_group(rg, columns=["case_name", "court_short_name", "date_filed", "opinions"]).to_pylist()
20
+ for r in t:
21
+ texts = []
22
+ for o in r["opinions"] or []:
23
+ tx = o.get("opinion_text") or ""
24
+ if "<" in tx and ">" in tx:
25
+ tx = html.unescape(tag.sub(" ", tx))
26
+ tx = re.sub(r"\n{3,}", "\n\n", tx.strip())
27
+ if tx:
28
+ texts.append(tx)
29
+ if not texts:
30
+ continue
31
+ head = f"{r['case_name'] or ''}, {r['court_short_name'] or ''}, {r['date_filed'] or ''}"
32
+ rec = head + "\n\n" + "\n\n".join(texts) + "\n\n\n"
33
+ out.write(rec)
34
+ n_dec += 1; n_bytes += len(rec.encode())
35
+ courts[r["court_short_name"]] = courts.get(r["court_short_name"], 0) + 1
36
+ if r["date_filed"]:
37
+ years.append(int(str(r["date_filed"])[:4]))
38
+ if n_bytes >= limit:
39
+ break
40
+ if n_bytes >= limit:
41
+ break
42
+ print(f"row group {rg}: {n_dec:,} decisions, {n_bytes/1e9:.2f} GB", flush=True)
43
+ years.sort()
44
+ meta = dict(source="harvard-lil/cold-cases (CC0), " + src.split("/")[-1], decisions=n_dec, bytes=n_bytes,
45
+ courts=len(courts), top_courts=sorted(courts.items(), key=lambda kv: -kv[1])[:12],
46
+ years=[years[0], years[len(years)//2], years[-1]] if years else None)
47
+ json.dump(meta, open(dst.rsplit(".", 1)[0] + ".json", "w"), indent=1)
48
+ print(json.dumps(meta, indent=1))
scripts/convert.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hand-written CiteNet -> Core ML (.mlpackage, fp16), plus parity and placement checks."""
2
+ import json, os, shutil, sys, time
3
+ import numpy as np, torch, coremltools as ct
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
6
+ from citenet import net as cn, text, spec
7
+ from lepc import load
8
+
9
+ OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "build")
10
+ # Fixed shapes: with EnumeratedShapes Core ML ran every shape on the CPU. With a
11
+ # fixed batch the planner puts all ops on the ANE from B=16 up; B=1 stays on CPU.
12
+ # b2048 / b8192 are for the GPU, which wants bigger batches.
13
+ BATCHES = [1, 32, 512, 2048, 8192]
14
+
15
+ model, meta = cn.make()
16
+ tot, nz = cn.param_counts(model)
17
+ paths = {}
18
+ for B in BATCHES:
19
+ traced = torch.jit.trace(model, torch.zeros(B, 1, 1, spec.SEQ))
20
+ ml = ct.convert(
21
+ traced,
22
+ inputs=[ct.TensorType(name="codes", shape=(B, 1, 1, spec.SEQ), dtype=np.float16)],
23
+ outputs=[ct.TensorType(name=n, dtype=np.float16) for n in ("p_fake", "evidence", "concepts")],
24
+ compute_precision=ct.precision.FLOAT16,
25
+ minimum_deployment_target=ct.target.macOS15,
26
+ )
27
+ ml.short_description = "CiteNet: phantom legal-citation detector with hand-written weights (no training)"
28
+ ml.user_defined_metadata.update({"params": str(tot), "nonzero_params": str(nz), "trained": "false",
29
+ "batch": str(B), "evidence": json.dumps(meta["evidence"])})
30
+ paths[B] = os.path.join(OUT, f"CiteNet_b{B}.mlpackage")
31
+ ml.save(paths[B])
32
+ print("saved", paths[B])
33
+ # lean builds for bulk scanning: no concepts output (only p_fake + evidence leave the accelerator)
34
+ class Lean(torch.nn.Module):
35
+ def __init__(self, m):
36
+ super().__init__(); self.m = m
37
+ def forward(self, x):
38
+ p, e, _ = self.m(x)
39
+ return p, e
40
+ for B in (512, 2048, 8192):
41
+ traced = torch.jit.trace(Lean(model).eval(), torch.zeros(B, 1, 1, spec.SEQ))
42
+ ml = ct.convert(traced, inputs=[ct.TensorType(name="codes", shape=(B, 1, 1, spec.SEQ), dtype=np.float16)],
43
+ outputs=[ct.TensorType(name=n, dtype=np.float16) for n in ("p_fake", "evidence")],
44
+ compute_precision=ct.precision.FLOAT16, minimum_deployment_target=ct.target.macOS15)
45
+ ml.short_description = "CiteNet lean (p_fake + evidence only), hand-written weights"
46
+ lp = os.path.join(OUT, f"CiteNet_lean_b{B}.mlpackage"); ml.save(lp)
47
+ dst = os.path.join(OUT, f"CiteNet_lean_b{B}.mlmodelc"); shutil.rmtree(dst, ignore_errors=True)
48
+ ct.utils.compile_model(lp, dst)
49
+ print("saved", lp)
50
+ json.dump({k: meta[k] for k in ("features", "concepts", "rules", "evidence", "groups", "anchored", "floating")},
51
+ open(os.path.join(OUT, "meta.json"), "w"))
52
+ np.savez_compressed(os.path.join(OUT, "weights.npz"), **{k: v for k, v in cn.build()[0].items()})
53
+
54
+ # ---- parity: torch fp32 vs Core ML fp16 on every train+eval citation
55
+ rows = load("aux_train") + load("eval")
56
+ X = text.encode([r["cite"] for r in rows])
57
+ with torch.no_grad():
58
+ pt = model(torch.from_numpy(X))[0][:, 0].numpy()
59
+ for B, cu in ((512, "CPU_AND_NE"), (2048, "CPU_AND_GPU")):
60
+ m = ct.models.MLModel(paths[B], compute_units=getattr(ct.ComputeUnit, cu))
61
+ outs = []
62
+ for i in range(0, len(X), B):
63
+ xb = X[i:i + B]; n = len(xb)
64
+ pad = np.zeros((B, 1, 1, spec.SEQ), np.float16); pad[:n] = xb
65
+ outs.append(m.predict({"codes": pad})["p_fake"][:n, 0])
66
+ pc = np.concatenate(outs).astype(np.float32)
67
+ flip = np.where((pc >= 0.5) != (pt >= 0.5))[0]
68
+ print(f"{cu} b{B} vs torch fp32: {len(X)-len(flip)}/{len(X)} labels agree; flips: {[rows[i]['cite'] for i in flip]}")
69
+
70
+ # ---- placement
71
+ from coremltools.models.compute_plan import MLComputePlan
72
+ for B, p in paths.items():
73
+ dst = os.path.join(OUT, f"CiteNet_b{B}.mlmodelc")
74
+ shutil.rmtree(dst, ignore_errors=True) # compile_model will not overwrite
75
+ c = ct.utils.compile_model(p, dst)
76
+ for cu in ("CPU_AND_NE", "CPU_AND_GPU"):
77
+ plan = MLComputePlan.load_from_path(path=c, compute_units=getattr(ct.ComputeUnit, cu))
78
+ cnt = {}
79
+ for fn in plan.model_structure.program.functions.values():
80
+ for op in fn.block.operations:
81
+ u = plan.get_compute_device_usage_for_mlprogram_operation(op)
82
+ if u:
83
+ k = type(u.preferred_compute_device).__name__.replace("ML", "").replace("ComputeDevice", "")
84
+ cnt[k] = cnt.get(k, 0) + 1
85
+ print(f"b{B} {cu} placement:", cnt)
scripts/evaluate.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score the hand-written CiteNet at citation level on a LePhantomCite split."""
2
+ import argparse, collections, os, sys
3
+ import numpy as np, torch
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
+ from citenet import net as cn, text
6
+ from lepc import load
7
+
8
+ ap = argparse.ArgumentParser()
9
+ ap.add_argument("split", default="aux_train", nargs="?")
10
+ ap.add_argument("--show", type=int, default=25)
11
+ a = ap.parse_args()
12
+
13
+ model, meta = cn.make()
14
+ rows = load(a.split)
15
+ x = torch.from_numpy(text.encode([r["cite"] for r in rows]))
16
+ with torch.no_grad():
17
+ p, ev, _ = model(x)
18
+ p = p[:, 0].numpy(); ev = ev.numpy()
19
+ lab = np.array([r["label"] for r in rows])
20
+ flag = p >= 0.5
21
+ tot, nz = cn.param_counts(model)
22
+ print(f"params {tot:,} (nonzero {nz:,})")
23
+ print(collections.Counter(lab))
24
+ tp = int((flag & (lab == "phantom")).sum()); fn = int((~flag & (lab == "phantom")).sum())
25
+ fp = int((flag & (lab == "genuine")).sum()); tn = int((~flag & (lab == "genuine")).sum())
26
+ oth = int((flag & (lab == "other")).sum())
27
+ print(f"phantom recall {tp}/{tp+fn} = {tp/max(1,tp+fn):.3f}")
28
+ print(f"genuine false-flag {fp}/{fp+tn} = {fp/max(1,fp+tn):.4f}")
29
+ print(f"precision {tp/max(1,tp+fp):.3f}")
30
+ print(f"other-hallucination cites flagged {oth}/{int((lab=='other').sum())}")
31
+ C = meta["concepts"]
32
+ cls = [i for i, n in enumerate(C) if n.startswith("class:")]
33
+ with torch.no_grad():
34
+ conc = model(x)[2].numpy()
35
+ multi = np.where(conc[:, cls].sum(1) > 1.5)[0]
36
+ print(f"cites where >1 reporter class fired: {len(multi)}", [rows[i]["cite"] for i in multi[:5]])
37
+ names = meta["evidence"]
38
+ def why(i):
39
+ return ", ".join(f"{n}={ev[i,j]:.2f}" for j, n in enumerate(names) if ev[i, j] > 0.01)
40
+ print("\n-- missed phantoms"); [print(f" {p[i]:.3f} {rows[i]['cite']!r} -> {text.normalize(rows[i]['cite'])!r} [{why(i)}]") for i in np.where(~flag & (lab=='phantom'))[0][:a.show]]
41
+ print("\n-- false flags"); [print(f" {p[i]:.3f} {rows[i]['cite']!r} -> {text.normalize(rows[i]['cite'])!r} [{why(i)}]") for i in np.where(flag & (lab=='genuine'))[0][:a.show]]
42
+ amber = np.where((p > 0.1) & (p < 0.5) & (lab == "genuine"))[0]
43
+ print(f"\n-- genuine but amber (0.1-0.5): {len(amber)}")
44
+ for i in amber[:a.show]: print(f" {p[i]:.3f} {text.normalize(rows[i]['cite'])!r} [{why(i)}]")
scripts/lepc.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Citation-level labels from LePhantomCite (ai-law-society-lab/Legal_Phantom_Citation).
2
+
3
+ phantom = citation tagged non_existent_citation
4
+ genuine = citation with no hallucination tag at all
5
+ other = tagged case_name_mismatch / wrong_pincite / misquote / content_misrepresentation
6
+ (needs a case-law lookup; out of scope for a string-only model, reported separately)
7
+ 'optional' tags (typos in the source brief) are dropped.
8
+ """
9
+ import json, os, sys
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+ from citenet.text import normalize
12
+
13
+ ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
14
+
15
+
16
+ def load(split):
17
+ rows = [json.loads(l) for l in open(os.path.join(ROOT, split + ".jsonl"))]
18
+ out = []
19
+ for r in rows:
20
+ tags = r.get("list_hallucination_types") or {}
21
+ opt = {k for k, v in (r.get("list_hallucinations") or {}).items() if v == "optional"}
22
+ cites = list(dict.fromkeys(list(r["citations_in_segment"]) + list(tags)))
23
+ seen = set()
24
+ for c in cites:
25
+ key = normalize(c)
26
+ if key in seen or c in opt:
27
+ continue
28
+ seen.add(key)
29
+ ts = tags.get(c, [])
30
+ if "optional" in ts:
31
+ continue
32
+ label = "phantom" if "non_existent_citation" in ts else ("other" if ts else "genuine")
33
+ out.append(dict(cite=c, label=label, types=ts, file=r["filename"]))
34
+ return out
scripts/scan_parity.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Check bench/scanparity (native Swift scanner) against citenet.text on a text file."""
2
+ import subprocess, sys, os
3
+ import numpy as np
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
+ from citenet import text, spec
6
+
7
+ path = sys.argv[1]
8
+ s = open(path, encoding="utf-8", newline="").read()
9
+ found = text.extract(s)
10
+ py_cites = [c for _, _, c in found]
11
+ py_codes = text.encode(py_cites).reshape(len(py_cites), spec.SEQ).astype(np.float16)
12
+ out = subprocess.run([os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bench", "scanparity"),
13
+ path, "/tmp/scanparity_out"], capture_output=True, text=True)
14
+ print(out.stdout.strip(), out.stderr.strip()[:500])
15
+ sw_codes = np.fromfile("/tmp/scanparity_out.f16", dtype=np.float16).reshape(-1, spec.SEQ)
16
+ sw_cites = [l.replace("\\r", "\r").replace("\\n", "\n") for l in open("/tmp/scanparity_out.txt", encoding="utf-8", newline="").read().split("\n")] if len(sw_codes) else []
17
+ print(f"python {len(py_cites):,} citations, swift {len(sw_codes):,}")
18
+ n = min(len(py_cites), len(sw_cites))
19
+ cite_mis = [i for i in range(n) if py_cites[i] != sw_cites[i]]
20
+ print(f"first {n:,}: cite text mismatches {len(cite_mis)}")
21
+ for i in cite_mis[:5]:
22
+ print(" py:", repr(py_cites[i]), "\n sw:", repr(sw_cites[i]))
23
+ if not cite_mis and len(py_cites) == len(sw_cites):
24
+ bad = np.where((py_codes != sw_codes).any(1))[0]
25
+ print(f"code rows identical: {len(py_cites) - len(bad):,}/{len(py_cites):,}")
26
+ inv = {v: k for k, v in spec.PUNCT.items()}
27
+ dec = lambda r: "".join(chr(48 + int(c) - 3) if 3 <= c <= 12 else chr(97 + int(c) - 13) if 13 <= c <= 38 else inv.get(int(c), "?") if c > 2 else "" for c in r)
28
+ for i in bad[:6]:
29
+ print(" ", repr(py_cites[i]), "\n py:", dec(py_codes[i]), "\n sw:", dec(sw_codes[i]))
scripts/train_baseline.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Same CiteNet architecture, trained instead of hand-written.
2
+
3
+ random : random init, Adam + class-weighted BCE on aux_train (genuine vs phantom)
4
+ handinit: start from the hand-written weights, same training
5
+ Scored on the LePhantomCite eval split, and on the planted fakes in the demo brief
6
+ (court mismatch / volume-year / state mismatch: kinds the train split's phantoms
7
+ never show, since they are all invented series).
8
+ """
9
+ import os, sys, copy
10
+ import numpy as np, torch, torch.nn.functional as F
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+ from citenet import net as cn, text
14
+ from lepc import load
15
+
16
+ torch.manual_seed(0); np.random.seed(0)
17
+ tr = [r for r in load("aux_train") if r["label"] != "other"]
18
+ ev = load("eval")
19
+ Xtr = torch.from_numpy(text.encode([r["cite"] for r in tr])); ytr = torch.tensor([r["label"] == "phantom" for r in tr], dtype=torch.float32)
20
+ Xev = torch.from_numpy(text.encode([r["cite"] for r in ev])); lev = np.array([r["label"] for r in ev])
21
+ brief = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "demo", "sample_brief.txt")).read()
22
+ bc = [c for _, _, c in text.extract(brief)]
23
+ hand, _ = cn.make()
24
+ with torch.no_grad():
25
+ bl = (hand(torch.from_numpy(text.encode(bc)))[0][:, 0] >= 0.5).numpy() # hand-written labels = planted truth here
26
+ Xb = torch.from_numpy(text.encode(bc))
27
+
28
+
29
+ def score(m, tag):
30
+ with torch.no_grad():
31
+ p = m(Xev)[0][:, 0].numpy(); pb = m(Xb)[0][:, 0].numpy()
32
+ f = p >= 0.5
33
+ rec = (f & (lev == "phantom")).sum(), (lev == "phantom").sum()
34
+ ff = (f & (lev == "genuine")).sum(), (lev == "genuine").sum()
35
+ fb = pb >= 0.5
36
+ ood = [c for c, t, y in zip(bc, bl, fb) if t and "th " not in c.split("(")[0]]
37
+ caught = sum(1 for c, t, y in zip(bc, bl, fb) if t and y and "th " not in c.split("(")[0])
38
+ series = sum(1 for c, t, y in zip(bc, bl, fb) if t and y and "th " in c.split("(")[0])
39
+ fpb = sum(1 for t, y in zip(bl, fb) if y and not t)
40
+ print(f"{tag:22s} eval phantoms {rec[0]}/{rec[1]} eval false flags {ff[0]}/{ff[1]} | brief: series fakes {series}/2, "
41
+ f"other fakes {caught}/{len(ood)}, real flagged {fpb}/{int((~bl).sum())}")
42
+
43
+
44
+ def train(m, steps=1500, lr=3e-3):
45
+ m.train()
46
+ opt = torch.optim.Adam(m.parameters(), lr=lr)
47
+ pw = torch.tensor((1 - ytr.mean()) / ytr.mean())
48
+ for s in range(steps):
49
+ idx = torch.randint(0, len(Xtr), (256,))
50
+ p = m(Xtr[idx])[0][:, 0].clamp(1e-6, 1 - 1e-6)
51
+ logit = torch.log(p) - torch.log1p(-p)
52
+ loss = F.binary_cross_entropy_with_logits(logit, ytr[idx], pos_weight=pw)
53
+ opt.zero_grad(); loss.backward(); opt.step()
54
+ return m.eval()
55
+
56
+
57
+ score(hand, "hand-written (0 steps)")
58
+ for seed in (0, 1, 2):
59
+ torch.manual_seed(seed)
60
+ rnd = cn.CiteNet(cn.build()[0])
61
+ for mod in rnd.modules():
62
+ if isinstance(mod, (torch.nn.Conv2d, torch.nn.Linear)):
63
+ mod.reset_parameters()
64
+ score(train(rnd), f"random init, seed {seed}")
65
+ torch.manual_seed(0)
66
+ score(train(copy.deepcopy(hand), lr=1e-4), "hand init + fine-tune")