code wiki / _hdl_build / nx_ecomat_domtrend_lib.nx
nx_ecomat_domtrend_lib.nx source
↩ module page · 285 lines · 11909 B
1// nx_ecomat_domtrend_lib.nx -- EXACT growth attribution from the per-domain ledger.
2//
3// This is the consumer that closes debt 1785514540. nx_ecomat_trend reads the AGGREGATE ledger and
4// can only attribute a span whose domain count held still -- measured 406 permil of real movement.
5// The per-domain ledger (nx_ecomat_domledger) records who held what, so the same question becomes
6// arithmetic instead of inference: diff two beats BY NAME.
7//
8// ADVANCEMENT = sum over domains present in BOTH beats of (cur_last - cur_first)
9// EXPANSION = the levels and bar carried in by domains present ONLY in the later beat
10// RETIRED = levels carried out by domains present ONLY in the earlier beat
11//
12// Nothing is ambiguous, because nothing is inferred from a total. A domain that was declared at the
13// bottom can no longer be mistaken for a domain that climbed, which was the entire defect: the
14// headline sum_cur/sum_bar falls when you declare a frontier, so the ecosystem's own self-grade
15// punished expansion and made honest growth unreadable.
16//
17// SNAPSHOT CHOICE: first beat vs last beat. Intermediate beats are counted and reported but not
18// diffed pairwise -- the endpoints answer "where are we vs where we started", and reporting the
19// beat count lets a reader see how much history backs that.
20// license_tier: ORIGINAL No hw writes (Rule 26).
21import "nx_ecomat_trend_lib.nx"
22
23const DT_LOG: *u8 = "knowledge/status/ecomat_domains.log"
24const DT_MAX_D: i64 = 256
25// MUST match the WRITER's cap (ECOMAT_DOM_CAP, used by edl_walk via ec_str). It was 64 while the
26// writer emitted up to 128: a 70-char name would be written whole and read back as a 63-char
27// PREFIX, and two names sharing that prefix would collide in dt_slot -- silently merging two
28// domains into one and corrupting every attribution derived from them.
29const DT_NAME_CAP: i64 = 128
30const DT_OUT_SLOTS: i64 = 24
31const DT_SPACE: i64 = 32
32
33const DTO_BEATS: i64 = 0
34const DTO_ROWS: i64 = 1
35const DTO_FIRST_EPOCH: i64 = 2
36const DTO_LAST_EPOCH: i64 = 3
37const DTO_FIRST_N: i64 = 4
38const DTO_LAST_N: i64 = 5
39const DTO_ADVANCE: i64 = 6
40const DTO_EXPAND_CUR: i64 = 7
41const DTO_EXPAND_BAR: i64 = 8
42const DTO_RETIRED_CUR: i64 = 9
43const DTO_COMMON: i64 = 10
44const DTO_NEW: i64 = 11
45const DTO_DECOMP_PERMIL: i64 = 12
46const DTO_CAPPED: i64 = 13
47
48const DT_ERR_NOLOG: i64 = 0 - 1
49const DT_ERR_ONEBEAT: i64 = 0 - 2
50// An endpoint beat whose ECOMATDOM-END terminator is missing, incomplete, or disagrees with the
51// rows actually present. Diffing a PARTIAL snapshot is the worst failure this lib has: every domain
52// missing from a truncated final beat reads as RETIRED, fabricating a retirement, with a number.
53const DT_ERR_TRUNCATED: i64 = 0 - 3
54// The READER hit DT_MAX_D. MUST NOT be reported as TRUNCATED: truncated means the WRITER did not
55// finish, capped means I could not hold what the writer correctly wrote. Opposite causes, opposite
56// remedies (fix the writer vs raise the cap) -- collapsing them is the conflation this lib prevents.
57const DT_ERR_CAPPED: i64 = 0 - 4
58
59const DT_END_TAG: *u8 = "ECOMATDOM-END"
60const DT_END_TAG_LEN: i64 = 13
61
62// copy the value of `domain=` from the line span into out (NUL-terminated); 1 on success.
63func dt_name(buf: *u8, ls: i64, le: i64, out: *u8) -> i64 {
64 let pat: *u8 = "domain=" as *u8
65 let pl: i64 = el_len(pat)
66 var i: i64 = ls
67 while i + pl <= le {
68 if el_match(buf, i, pat, pl) == 1 {
69 var j: i64 = i + pl
70 var o: i64 = 0
71 var over: i64 = 0
72 while j < le {
73 if (buf[j] as i64) == DT_SPACE { j = le } else {
74 // REFUSE, never truncate. A silently shortened name can collide with another
75 // domain sharing its prefix, merging two domains in dt_slot and corrupting
76 // attribution with no signal -- strictly worse than skipping the row loudly.
77 if o < DT_NAME_CAP - 1 { out[o] = buf[j]; o = o + 1 } else { over = 1; j = le }
78 j = j + 1
79 }
80 }
81 out[o] = 0 as u8
82 if over == 1 { out[0] = 0 as u8; return 0 - 1 }
83 if o > 0 { return 1 }
84 return 0
85 }
86 i = i + 1
87 }
88 return 0
89}
90
91// Declared domain count from the ECOMATDOM-END terminator for `epoch`; -1 when that beat has no
92// terminator or is not flagged complete. THIS IS THE SIGNAL edl_end WRITES AND NOTHING READ:
93// a beat without a valid END is a beat the writer did not finish, and must never be differenced.
94// Bounds: the tag compare is length-checked against the LINE, because el_match takes no haystack
95// length and will happily read past a short line (the defect filed against el_last_green).
96func dt_end_count(buf: *u8, n: i64, epoch: i64) -> i64 {
97 var p: i64 = 0
98 while p < n {
99 var e: i64 = p
100 var go: i64 = 1
101 while go == 1 {
102 if e >= n { go = 0 } else { if (buf[e] as i64) == TR_NL { go = 0 } else { e = e + 1 } }
103 }
104 if e - p >= DT_END_TAG_LEN {
105 if el_match(buf, p, DT_END_TAG, DT_END_TAG_LEN) == 1 {
106 if tr_field(buf, p, e, "epoch=" as *u8) == epoch {
107 if tr_field(buf, p, e, "coverage_complete=" as *u8) == 1 {
108 return tr_field(buf, p, e, "domains=" as *u8)
109 }
110 return 0 - 1
111 }
112 }
113 }
114 p = e + 1
115 }
116 return 0 - 1
117}
118
119func dt_slot(names: *u8, n: i64, want: *u8) -> i64 {
120 var i: i64 = 0
121 while i < n {
122 if el_streq(names + i * DT_NAME_CAP, want) == 1 { return i }
123 i = i + 1
124 }
125 return 0 - 1
126}
127
128// Exact decomposition between the first and last beat in the per-domain ledger.
129// PURE core -- the gate proves this on in-memory fixtures, so it can never pass by reading back a
130// file it produced itself.
131func em_domtrend_buf(buf: *u8, n: i64, out: *i64) -> i64 {
132 var z: i64 = 0
133 while z < DT_OUT_SLOTS { out[z] = 0; z = z + 1 }
134 if n <= 0 { return DT_ERR_NOLOG }
135
136 // pass 1 -- find the first and last DATA epoch (END rows carry no domain= and are skipped)
137 var first_e: i64 = 0 - 1
138 var last_e: i64 = 0 - 1
139 var rows: i64 = 0
140 let nm: *u8 = sys_mmap(DT_NAME_CAP)
141 var p: i64 = 0
142 while p < n {
143 var e: i64 = p
144 var go: i64 = 1
145 while go == 1 {
146 if e >= n { go = 0 } else { if (buf[e] as i64) == TR_NL { go = 0 } else { e = e + 1 } }
147 }
148 if e > p { if dt_name(buf, p, e, nm) == 1 {
149 let ep: i64 = tr_field(buf, p, e, "epoch=" as *u8)
150 if ep >= 0 {
151 rows = rows + 1
152 if first_e < 0 { first_e = ep }
153 if ep < first_e { first_e = ep }
154 if ep > last_e { last_e = ep }
155 }
156 } }
157 p = e + 1
158 }
159 out[DTO_ROWS] = rows
160 out[DTO_FIRST_EPOCH] = first_e
161 out[DTO_LAST_EPOCH] = last_e
162 if rows == 0 { return DT_ERR_NOLOG }
163 if last_e == first_e { return DT_ERR_ONEBEAT }
164
165 // pass 2 -- collect the two endpoint snapshots by name
166 let an: *u8 = sys_mmap(DT_MAX_D * DT_NAME_CAP)
167 let ac: *i64 = sys_mmap(DT_MAX_D * 8) as *i64
168 let bn: *u8 = sys_mmap(DT_MAX_D * DT_NAME_CAP)
169 let bc: *i64 = sys_mmap(DT_MAX_D * 8) as *i64
170 let bb: *i64 = sys_mmap(DT_MAX_D * 8) as *i64
171 var na: i64 = 0
172 var nb: i64 = 0
173 var capped: i64 = 0
174 var beats: i64 = 0
175 var seen_last: i64 = 0 - 1
176
177 p = 0
178 while p < n {
179 var e2: i64 = p
180 var g2: i64 = 1
181 while g2 == 1 {
182 if e2 >= n { g2 = 0 } else { if (buf[e2] as i64) == TR_NL { g2 = 0 } else { e2 = e2 + 1 } }
183 }
184 if e2 > p { if dt_name(buf, p, e2, nm) == 1 {
185 let ep2: i64 = tr_field(buf, p, e2, "epoch=" as *u8)
186 let cu: i64 = tr_field(buf, p, e2, "cur=" as *u8)
187 let ba: i64 = tr_field(buf, p, e2, "bar=" as *u8)
188 if ep2 != seen_last { beats = beats + 1; seen_last = ep2 }
189 if ep2 == first_e { if cu >= 0 {
190 if na < DT_MAX_D {
191 var q: i64 = 0
192 while q < DT_NAME_CAP { an[na * DT_NAME_CAP + q] = nm[q]; q = q + 1 }
193 ac[na] = cu
194 na = na + 1
195 } else { capped = 1 }
196 } }
197 if ep2 == last_e { if cu >= 0 {
198 if nb < DT_MAX_D {
199 var r: i64 = 0
200 while r < DT_NAME_CAP { bn[nb * DT_NAME_CAP + r] = nm[r]; r = r + 1 }
201 bc[nb] = cu
202 bb[nb] = ba
203 nb = nb + 1
204 } else { capped = 1 }
205 } }
206 } }
207 p = e2 + 1
208 }
209
210 out[DTO_BEATS] = beats
211 out[DTO_FIRST_N] = na
212 out[DTO_LAST_N] = nb
213 out[DTO_CAPPED] = capped
214
215 // Capacity FIRST, before the terminator comparison -- otherwise a store larger than DT_MAX_D
216 // makes na/nb cap at the limit while the terminator honestly declares more, the counts mismatch,
217 // and we would blame the WRITER for the READER's limit. Diagnose the right one.
218 if capped == 1 { return DT_ERR_CAPPED }
219
220 // BOTH endpoints must carry a valid terminator whose declared count matches the rows we read.
221 // Refuse rather than diff a partial snapshot: a truncated final beat would classify every
222 // missing domain as RETIRED and report it as fact. REFUSE, NEVER SILENTLY SHRINK.
223 let end_a: i64 = dt_end_count(buf, n, first_e)
224 let end_b: i64 = dt_end_count(buf, n, last_e)
225 if end_a != na { return DT_ERR_TRUNCATED }
226 if end_b != nb { return DT_ERR_TRUNCATED }
227
228 var advance: i64 = 0
229 var expcur: i64 = 0
230 var expbar: i64 = 0
231 var common: i64 = 0
232 var fresh: i64 = 0
233 var i2: i64 = 0
234 while i2 < nb {
235 let s: i64 = dt_slot(an, na, bn + i2 * DT_NAME_CAP)
236 if s >= 0 {
237 advance = advance + (bc[i2] - ac[s])
238 common = common + 1
239 } else {
240 expcur = expcur + bc[i2]
241 expbar = expbar + bb[i2]
242 fresh = fresh + 1
243 }
244 i2 = i2 + 1
245 }
246
247 var retired: i64 = 0
248 var i3: i64 = 0
249 while i3 < na {
250 if dt_slot(bn, nb, an + i3 * DT_NAME_CAP) < 0 { retired = retired + ac[i3] }
251 i3 = i3 + 1
252 }
253
254 out[DTO_ADVANCE] = advance
255 out[DTO_EXPAND_CUR] = expcur
256 out[DTO_EXPAND_BAR] = expbar
257 out[DTO_RETIRED_CUR] = retired
258 out[DTO_COMMON] = common
259 out[DTO_NEW] = fresh
260 // Every level is attributed by NAME here, so attribution is total -- unless the domain cap bound,
261 // in which case say so rather than claim completeness over a truncated read.
262 // 1000 here is EARNED, not asserted. We only reach this line after BOTH endpoint beats proved
263 // complete against their own ECOMATDOM-END counts, so every level is attributed to a NAMED
264 // bucket (climbed / declared / retired). Before that check existed this was a bare constant
265 // returned whenever the walk finished -- a number that could not go down, printed on /sota
266 // beside genuinely computed figures. If the cap bound, coverage is UNKNOWN: emit -1 UNMEASURED,
267 // never 0, because 0 reads as "nothing was attributable", a different and false claim.
268 if capped == 1 { out[DTO_DECOMP_PERMIL] = 0 - 1 } else { out[DTO_DECOMP_PERMIL] = TR_PERMIL }
269 return 0
270}
271
272// Thin file wrapper. A missing ledger is DT_ERR_NOLOG, never a silent zero -- "no per-domain
273// history yet" and "nothing advanced" are opposite statements and must never share a return value.
274func em_domtrend(logpath: *u8, out: *i64) -> i64 {
275 let szp: *i64 = sys_mmap(16) as *i64
276 let buf: *u8 = ss_readall(logpath, szp)
277 var n: i64 = szp[0]
278 if n < 0 { n = 0 }
279 if n == 0 {
280 var z2: i64 = 0
281 while z2 < DT_OUT_SLOTS { out[z2] = 0; z2 = z2 + 1 }
282 return DT_ERR_NOLOG
283 }
284 return em_domtrend_buf(buf, n, out)
285}