nx_clause_lib.nx source
↩ module page · 231 lines · 8323 B
1// nx_clause_lib.nx -- CONTRACT TEXT REASONING: clause retrieval, redline deviation, playbook risk.
2// u2605THIS IS THE QUALITATIVE GAP. Every other legal organ here computes over facts a caller supplies.
3// This one READS THE DOCUMENT: it locates a clause in raw contract text, RETRIEVES the closest standard
4// clause from a playbook corpus, measures how far the draft has drifted, and scores the risk. That is
5// the deterministic core of what Ironclad's Jurist and DocuSign's IAM agents do during negotiation.
6//
7// u2605TWO DIRECTIONS OF DRIFT, MEASURED SEPARATELY -- because they are different problems and a single
8// "similarity" number hides both:
9// RETAINED : how much of the STANDARD clause survives in the draft. Low retention = protections were
10// DELETED. This is what silently strips an indemnity or a limitation of liability.
11// NOVEL : how much of the DRAFT is language the standard never had. High novelty = terms were
12// INSERTED -- an added carve-out, an uncapped obligation, a new termination right.
13// A redline can score 900 on one and 100 on the other. Averaging them into one figure is how a
14// dangerous edit passes review looking "95% similar".
15//
16// u2605RETRIEVAL IS BY MEASUREMENT, NOT BY LABEL. cl_best_match scans the playbook and returns the clause
17// with the highest retention against the draft. It never trusts a heading: a paragraph titled
18// "Limitation of Liability" that actually reads like an indemnity will match the indemnity standard,
19// which is exactly the mislabel a human reviewer skims past.
20//
21// u2605FAIL-CLOSED: an empty draft, an empty playbook, or a clause that matches NOTHING above the floor
22// returns CL_NO_MATCH -- never "matches the first entry". An unmatched clause is escalated, not guessed.
23//
24// Thresholds are DATA (rule 11) as named constants in per mille, never literals in branches.
25// STRUCTURE: pure text functions, no storage, no I/O. license_tier: ORIGINAL LIB.
26
27import "nx_matter_lib.nx"
28
29const CL_NO_MATCH: i64 = 0 - 1
30
31const CL_RISK_NONE: i64 = 0
32const CL_RISK_LOW: i64 = 1
33const CL_RISK_MEDIUM: i64 = 2
34const CL_RISK_HIGH: i64 = 3
35const CL_RISK_BLOCK: i64 = 4
36
37const CL_MATCH_FLOOR: i64 = 300
38const CL_DEV_LOW: i64 = 100
39const CL_DEV_MEDIUM: i64 = 250
40const CL_DEV_HIGH: i64 = 500
41const CL_NOVEL_ALERT: i64 = 400
42
43const CL_WORDBUF: i64 = 256
44
45// ---- character + word primitives ----
46
47func cl_lower(c: i64) -> i64 {
48 if c >= 65 {
49 if c <= 90 { return c + 32 }
50 }
51 return c
52}
53
54// 1 for a word character (letters and digits); everything else is a separator
55func cl_is_word(c: i64) -> i64 {
56 if c >= 48 {
57 if c <= 57 { return 1 }
58 }
59 if c >= 65 {
60 if c <= 90 { return 1 }
61 }
62 if c >= 97 {
63 if c <= 122 { return 1 }
64 }
65 return 0
66}
67
68// extract the next lowercased word starting at or after i; writes into out, returns the index AFTER it.
69// out is empty when no word remains.
70func cl_word_at(s: *u8, i: i64, out: *u8) -> i64 {
71 var p: i64 = i
72 while s[p] != (0 as u8) {
73 if cl_is_word(s[p]) == 1 { break }
74 p = p + 1
75 }
76 var n: i64 = 0
77 while s[p] != (0 as u8) {
78 if cl_is_word(s[p]) == 0 { break }
79 if n < (CL_WORDBUF - 1) {
80 out[n] = cl_lower(s[p]) as u8
81 n = n + 1
82 }
83 p = p + 1
84 }
85 out[n] = 0 as u8
86 return p
87}
88
89func cl_word_count(s: *u8) -> i64 {
90 let w: *u8 = sys_mmap(CL_WORDBUF)
91 var i: i64 = 0
92 var n: i64 = 0
93 while 1 == 1 {
94 let nx: i64 = cl_word_at(s, i, w)
95 if w[0] == (0 as u8) { return n }
96 n = n + 1
97 i = nx
98 }
99 return n
100}
101
102// 1 if `word` appears in `hay` as a WHOLE word (case-insensitive). Substring matching would let
103// "liability" satisfy a search for "ability", so the boundary check is load-bearing.
104func cl_has_word(hay: *u8, word: *u8) -> i64 {
105 let w: *u8 = sys_mmap(CL_WORDBUF)
106 var i: i64 = 0
107 while 1 == 1 {
108 let nx: i64 = cl_word_at(hay, i, w)
109 if w[0] == (0 as u8) { return 0 }
110 if mt_streq(w, word) == 1 { return 1 }
111 i = nx
112 }
113 return 0
114}
115
116// ---- the two drift measures ----
117
118// u2605RETAINED: fraction (per mille) of the STANDARD's words that still appear in the draft.
119// Low retention means protections were DELETED.
120func cl_retained_permille(standard: *u8, draft: *u8) -> i64 {
121 let w: *u8 = sys_mmap(CL_WORDBUF)
122 var i: i64 = 0
123 var total: i64 = 0
124 var hit: i64 = 0
125 while 1 == 1 {
126 let nx: i64 = cl_word_at(standard, i, w)
127 if w[0] == (0 as u8) { break }
128 total = total + 1
129 if cl_has_word(draft, w) == 1 { hit = hit + 1 }
130 i = nx
131 }
132 if total == 0 { return 0 }
133 return (hit * 1000) / total
134}
135
136// u2605NOVEL: fraction (per mille) of the DRAFT's words that the standard never contained.
137// High novelty means terms were INSERTED.
138func cl_novel_permille(standard: *u8, draft: *u8) -> i64 {
139 let w: *u8 = sys_mmap(CL_WORDBUF)
140 var i: i64 = 0
141 var total: i64 = 0
142 var newn: i64 = 0
143 while 1 == 1 {
144 let nx: i64 = cl_word_at(draft, i, w)
145 if w[0] == (0 as u8) { break }
146 total = total + 1
147 if cl_has_word(standard, w) == 0 { newn = newn + 1 }
148 i = nx
149 }
150 if total == 0 { return 0 }
151 return (newn * 1000) / total
152}
153
154// deviation is the shortfall in retention -- what the draft dropped.
155func cl_deviation_permille(standard: *u8, draft: *u8) -> i64 {
156 return 1000 - cl_retained_permille(standard, draft)
157}
158
159// ---- retrieval over the playbook corpus ----
160
161// u2605scan the playbook and return the INDEX of the clause the draft best matches by RETENTION,
162// or CL_NO_MATCH when nothing clears the floor. lib is an array of *u8 clause texts.
163func cl_best_match(draft: *u8, lib: *i64, n: i64) -> i64 {
164 if n <= 0 { return CL_NO_MATCH }
165 var best: i64 = CL_NO_MATCH
166 var bestscore: i64 = 0
167 var i: i64 = 0
168 while i < n {
169 let sc: i64 = cl_retained_permille(lib[i] as *u8, draft)
170 if sc > bestscore {
171 bestscore = sc
172 best = i
173 }
174 i = i + 1
175 }
176 if bestscore < CL_MATCH_FLOOR { return CL_NO_MATCH }
177 return best
178}
179
180func cl_best_score(draft: *u8, lib: *i64, n: i64) -> i64 {
181 let idx: i64 = cl_best_match(draft, lib, n)
182 if idx == CL_NO_MATCH { return 0 }
183 return cl_retained_permille(lib[idx] as *u8, draft)
184}
185
186// ---- risk scoring against the playbook ----
187
188// u2605criticality (0..1000) is how much the clause matters -- an indemnity is not a notices clause.
189// An unmatched clause is BLOCK: an unrecognised term in a contract is the one a reviewer must see.
190func cl_risk_pure(deviation: i64, novel: i64, criticality: i64) -> i64 {
191 if deviation < 0 { return CL_RISK_BLOCK }
192 var r: i64 = CL_RISK_NONE
193 if deviation > CL_DEV_LOW { r = CL_RISK_LOW }
194 if deviation > CL_DEV_MEDIUM { r = CL_RISK_MEDIUM }
195 if deviation > CL_DEV_HIGH { r = CL_RISK_HIGH }
196 // inserted language is escalated independently of deletion
197 if novel > CL_NOVEL_ALERT {
198 if r < CL_RISK_MEDIUM { r = CL_RISK_MEDIUM }
199 }
200 // on a critical clause, any material drift is escalated one tier and never sits below MEDIUM
201 if criticality >= 700 {
202 if r > CL_RISK_NONE {
203 r = r + 1
204 if r < CL_RISK_MEDIUM { r = CL_RISK_MEDIUM }
205 if r > CL_RISK_BLOCK { r = CL_RISK_BLOCK }
206 }
207 }
208 return r
209}
210
211// the review verdict a negotiator acts on: 1 only when the clause may pass without escalation.
212func cl_auto_approve_pure(risk: i64) -> i64 {
213 if risk <= CL_RISK_LOW { return 1 }
214 return 0
215}
216
217// an unmatched clause always escalates, whatever its text looks like.
218func cl_unmatched_risk_pure(match_idx: i64) -> i64 {
219 if match_idx == CL_NO_MATCH { return CL_RISK_BLOCK }
220 return CL_RISK_NONE
221}
222
223func cl_risk_label(r: i64, out: *u8) -> i64 {
224 if r == CL_RISK_BLOCK { mt_catcopy(out, 0, "BLOCK-ESCALATE" as *u8); out[14] = 0 as u8; return 14 }
225 if r == CL_RISK_HIGH { mt_catcopy(out, 0, "HIGH" as *u8); out[4] = 0 as u8; return 4 }
226 if r == CL_RISK_MEDIUM { mt_catcopy(out, 0, "MEDIUM" as *u8); out[6] = 0 as u8; return 6 }
227 if r == CL_RISK_LOW { mt_catcopy(out, 0, "LOW" as *u8); out[3] = 0 as u8; return 3 }
228 mt_catcopy(out, 0, "NONE" as *u8)
229 out[4] = 0 as u8
230 return 4
231}