1use serde::{Deserialize, Serialize};
16
17use super::extract::{classify_deontic, extract_privacy_signals};
18use super::graph::ContextUnit;
19
20pub const COF_PROFILE: &str = "https://ns.webcivics.net/cof/profile/html-rdfa-1";
22pub const COF_NS: &str = "https://ns.webcivics.net/cof/";
23pub const CML_NS: &str = "https://ns.webcivics.net/cml/";
24pub const VALUES_NS: &str = "https://ns.webcivics.net/values/";
25pub const MEDIA_TYPE_COF: &str =
26 "text/html;profile=\"https://ns.webcivics.net/cof/profile/html-rdfa-1\"";
27
28pub const DEFAULT_SEGMENT_MAX_CHARS: usize = 24_000;
30pub const DEFAULT_SEGMENT_MIN_CHARS: usize = 2_000;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CofSegment {
36 pub index: u32,
38 pub total: u32,
39 pub id: String,
40 pub title: String,
41 pub html: String,
43 pub char_count: usize,
44 pub approx_tokens: usize,
46 pub unit_frags: Vec<String>,
48 pub is_index: bool,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct CofPackage {
54 pub document_uri: String,
55 pub title: String,
56 pub profile: String,
57 pub segment_max_chars: usize,
58 pub segments: Vec<CofSegment>,
59 pub total_chars: usize,
60 pub total_approx_tokens: usize,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum CofStyle {
65 AgentLean,
67 DualSurface,
69}
70
71impl Default for CofStyle {
72 fn default() -> Self {
73 Self::AgentLean
74 }
75}
76
77fn esc(s: &str) -> String {
78 let mut out = String::with_capacity(s.len());
79 for c in s.chars() {
80 match c {
81 '&' => out.push_str("&"),
82 '<' => out.push_str("<"),
83 '>' => out.push_str(">"),
84 '"' => out.push_str("""),
85 '\'' => out.push_str("'"),
86 _ => out.push(c),
87 }
88 }
89 out
90}
91
92fn approx_tokens(chars: usize) -> usize {
93 chars.div_ceil(4)
94}
95
96fn prefix_attr() -> String {
97 format!(
98 "rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# \
99 rdfs: http://www.w3.org/2000/01/rdf-schema# \
100 dc: http://purl.org/dc/terms/ \
101 skos: http://www.w3.org/2004/02/skos/core# \
102 prov: http://www.w3.org/ns/prov# \
103 cml: {CML_NS} \
104 values: {VALUES_NS} \
105 cof: {COF_NS}"
106 )
107}
108
109fn dual_css() -> &'static str {
110 "body{font:15px/1.45 system-ui,sans-serif;max-width:48rem;margin:1rem auto;padding:0 1rem;color:#111}\
112 h1{font-size:1.25rem;margin:0 0 .5rem}h2,h3{font-size:1rem;margin:1rem 0 .35rem}\
113 .banner{border-left:3px solid #6a4c93;background:#f4f1f7;padding:.5rem .75rem;margin:.75rem 0;font-size:.85rem}\
114 section{margin:.9rem 0;padding-bottom:.5rem;border-bottom:1px solid #eee}\
115 .text{white-space:pre-wrap}.meta{color:#666;font-size:.8rem}\
116 .sig{display:inline-block;font-size:.7rem;background:#eee;border-radius:999px;padding:.05rem .4rem;margin:.1rem}\
117 aside.logic{font:12px ui-monospace,monospace;background:#f7f5fa;border-left:2px solid #6a4c93;padding:.4rem .6rem;margin:.4rem 0}"
118}
119
120pub fn render_unit_fragment(doc_uri: &str, unit: &ContextUnit, style: CofStyle) -> String {
122 let _ = style;
123 let frag = esc(&unit.frag);
124 let label = esc(&unit.label);
125 let kind = esc(&unit.kind);
126 let about = esc(&format!("{doc_uri}#{}", unit.frag));
127 let resource = about.clone();
128 let (deontic, dconf) = if unit.text.trim().is_empty() {
129 (super::extract::DeonticClass::Undertaking, 0u8)
130 } else {
131 classify_deontic(&unit.text)
132 };
133 let privacy = extract_privacy_signals(&unit.text);
134 let signals: Vec<String> = privacy
135 .iter()
136 .map(|s| format!("privacy:{}", s.signal))
137 .chain(std::iter::once(format!("deontic:{}", deontic.as_str())))
138 .collect();
139 let sig_attr = esc(&signals.join(" "));
140 let conf = format!("{:.2}", dconf as f32 / 100.0);
141 let page_attr = unit
142 .page
143 .map(|p| format!(" data-page=\"{p}\""))
144 .unwrap_or_default();
145 let part_of = unit
146 .parent
147 .as_ref()
148 .map(|p| {
149 format!(
150 " rel=\"values:partOf\" resource=\"{}\"",
151 esc(&format!("{doc_uri}#{p}"))
152 )
153 })
154 .unwrap_or_default();
155
156 let mut sig_chips = String::new();
157 for s in &signals {
158 sig_chips.push_str(&format!(
159 "<span class=\"sig\" property=\"cml:hasSignal\" content=\"{}\">{}</span> ",
160 esc(s),
161 esc(s)
162 ));
163 }
164
165 let text_block = if unit.text.trim().is_empty() {
166 String::new()
167 } else {
168 let claim_id = format!("{doc_uri}#{}-claim", unit.frag);
169 format!(
170 "<div class=\"text\" typeof=\"cof:Block\" property=\"cof:hasBlock values:originalText\" \
171 resource=\"{doc_uri}#{frag}-text\"{page_attr}>\
172 <span typeof=\"cof:Claim\" property=\"cof:hasClaim\" about=\"{claim}\" \
173 resource=\"{claim}\" data-confidence=\"{conf}\" data-deontic=\"{deontic}\" \
174 data-signals=\"{sig_attr}\">{text}</span></div>",
175 claim = esc(&claim_id),
176 deontic = deontic.as_str(),
177 text = esc(&unit.text),
178 )
179 };
180
181 let logic =
182 if matches!(deontic, super::extract::DeonticClass::Undertaking) && privacy.is_empty() {
183 String::new()
184 } else {
185 format!(
186 "<aside class=\"logic\" typeof=\"cml:LogicApplication\" property=\"cml:asserts\" \
187 about=\"{about}-norm\">\
188 <span property=\"cml:modality\" resource=\"cml:Deontic\">deontic</span> \
189 <span property=\"values:deonticClass\">{deontic}</span> · conf {conf} · \
190 <span property=\"cml:curationStatus\" resource=\"cml:Proposed\">cml:Proposed</span>\
191 </aside>",
192 deontic = deontic.as_str(),
193 )
194 };
195
196 format!(
197 "<section id=\"{frag}\" typeof=\"cml:Concept cof:Section\" about=\"{about}\" \
198 resource=\"{resource}\" property=\"cof:hasSection\" data-kind=\"{kind}\"{page_attr}{part_of} \
199 data-confidence=\"{conf}\" data-deontic=\"{deontic}\" data-signals=\"{sig_attr}\">\
200 <h3><span property=\"skos:prefLabel cof:title\">{label}</span></h3>\
201 <div class=\"meta\">{sig_chips}</div>\
202 {text_block}{logic}\
203 <link rel=\"cml:realizedBy\" href=\"{doc_uri}#{frag}\" />\
204 <link rel=\"cml:curationStatus\" href=\"{CML_NS}Proposed\" />\
205 </section>\n",
206 deontic = deontic.as_str(),
207 )
208}
209
210fn wrap_document(
211 doc_uri: &str,
212 title: &str,
213 segment: &CofSegmentMeta,
214 body_inner: &str,
215 style: CofStyle,
216) -> String {
217 let prefix = prefix_attr();
218 let css = match style {
219 CofStyle::AgentLean => String::new(),
220 CofStyle::DualSurface => format!("<style>{}</style>", dual_css()),
221 };
222 let banner = match style {
223 CofStyle::AgentLean => format!(
224 "<div property=\"cml:curationStatus\" resource=\"cml:Proposed\" \
225 content=\"cml:Proposed\">cml:Proposed · COF segment {}/{} · profile {COF_PROFILE}</div>",
226 segment.index + 1,
227 segment.total
228 ),
229 CofStyle::DualSurface => format!(
230 "<div class=\"banner\">⚑ <strong>cml:Proposed</strong> — machine layer only. \
231 COF segment <span property=\"cof:segmentIndex\" content=\"{}\">{}/{}</span> · \
232 profile <span property=\"cof:profile\" content=\"{COF_PROFILE}\">{COF_PROFILE}</span>. \
233 Load only the segments you need (token optimisation).</div>",
234 segment.index,
235 segment.index + 1,
236 segment.total
237 ),
238 };
239 let nav = {
240 let mut n = String::new();
241 if let Some(p) = &segment.prev_id {
242 n.push_str(&format!(
243 "<link rel=\"cof:prevSegment\" href=\"{}\" />\n",
244 esc(p)
245 ));
246 }
247 if let Some(nx) = &segment.next_id {
248 n.push_str(&format!(
249 "<link rel=\"cof:nextSegment\" href=\"{}\" />\n",
250 esc(nx)
251 ));
252 }
253 n.push_str(&format!(
254 "<meta name=\"cof-segment\" content=\"{}\">\n\
255 <meta name=\"cof-segment-total\" content=\"{}\">\n\
256 <meta name=\"cof-segment-id\" content=\"{}\">\n",
257 segment.index,
258 segment.total,
259 esc(&segment.id)
260 ));
261 n
262 };
263
264 format!(
265 r#"<!DOCTYPE html>
266<html lang="en" prefix="{prefix}">
267<head>
268<meta charset="utf-8">
269<meta name="viewport" content="width=device-width, initial-scale=1">
270<meta name="cof-profile" content="{COF_PROFILE}">
271<meta name="cml-schema" content="2">
272{nav}{css}
273<title>{title_esc} — COF {seg_label}</title>
274</head>
275<body typeof="cof:Document" about="{doc}" resource="{doc}" vocab="{COF_NS}"
276 data-cof-segment="{idx}" data-cof-segment-total="{total}">
277<header>
278 <h1 property="dc:title cof:title">{title_esc}</h1>
279 <div class="meta">HTML+RDFa COF · CML TEXT→CONCEPT→LOGIC · engine=qualia-rust</div>
280 <link rel="prov:wasDerivedFrom" href="{doc}" />
281 {banner}
282</header>
283<main property="cof:body">
284{body}
285</main>
286</body>
287</html>
288"#,
289 title_esc = esc(title),
290 seg_label = if segment.is_index {
291 "index".into()
292 } else {
293 format!("seg-{}", segment.index)
294 },
295 doc = esc(doc_uri),
296 idx = segment.index,
297 total = segment.total,
298 body = body_inner,
299 )
300}
301
302struct CofSegmentMeta {
303 index: u32,
304 total: u32,
305 id: String,
306 prev_id: Option<String>,
307 next_id: Option<String>,
308 is_index: bool,
309}
310
311pub fn pack_units_into_segments(
313 units: &[ContextUnit],
314 max_chars: usize,
315) -> Vec<(Vec<usize>, usize)> {
316 let mut packs: Vec<(Vec<usize>, usize)> = Vec::new();
318 let mut cur: Vec<usize> = Vec::new();
319 let mut cur_chars = 0usize;
320 let max_chars = max_chars.max(DEFAULT_SEGMENT_MIN_CHARS);
321
322 for (i, u) in units.iter().enumerate() {
323 let unit_chars = u.label.len() + u.text.len() + 200;
325 if unit_chars > max_chars {
326 if !cur.is_empty() {
328 packs.push((cur, cur_chars));
329 cur = Vec::new();
330 cur_chars = 0;
331 }
332 packs.push((vec![i], unit_chars));
333 continue;
334 }
335 if !cur.is_empty() && cur_chars + unit_chars > max_chars {
336 packs.push((cur, cur_chars));
337 cur = Vec::new();
338 cur_chars = 0;
339 }
340 cur.push(i);
341 cur_chars += unit_chars;
342 }
343 if !cur.is_empty() {
344 packs.push((cur, cur_chars));
345 }
346 if packs.is_empty() {
347 packs.push((Vec::new(), 0));
348 }
349 packs
350}
351
352pub fn build_cof_package(
354 doc_uri: &str,
355 title: &str,
356 units: &[ContextUnit],
357 max_chars: usize,
358 style: CofStyle,
359) -> CofPackage {
360 let packs = pack_units_into_segments(units, max_chars);
361 let body_count = packs.len() as u32;
362 let total = body_count + 1;
364
365 let mut index_body = String::from(
367 "<nav typeof=\"cof:Section\" property=\"cof:hasSection\" about=\"#index\" id=\"index\">\n\
368 <h2 property=\"cof:title\">Index (token-cheap map)</h2>\n\
369 <ol>\n",
370 );
371 for (seg_i, (idxs, _chars)) in packs.iter().enumerate() {
372 let seg_id = format!("{doc_uri}#cof-seg-{}", seg_i + 1);
373 index_body.push_str(&format!(
374 "<li property=\"cof:hasSegment\" resource=\"{}\">segment {} · {} unit(s)<ul>",
375 esc(&seg_id),
376 seg_i + 1,
377 idxs.len()
378 ));
379 for &ui in idxs {
380 let u = &units[ui];
381 let (deontic, _) = classify_deontic(&u.text);
382 let priv_n = extract_privacy_signals(&u.text).len();
383 index_body.push_str(&format!(
384 "<li><a property=\"cof:ref\" href=\"{doc_uri}#{frag}\" resource=\"{doc_uri}#{frag}\">{label}</a> \
385 <span class=\"meta\">deontic:{deontic} · privacy:{priv_n}</span></li>",
386 frag = esc(&u.frag),
387 label = esc(&u.label),
388 deontic = deontic.as_str(),
389 ));
390 }
391 index_body.push_str("</ul></li>\n");
392 }
393 index_body.push_str("</ol>\n<p class=\"meta\">Load body segments by <code>cof:nextSegment</code> / segment id. Bodies hold <code>values:originalText</code> claims.</p>\n</nav>\n");
394
395 let mut segment_ids: Vec<String> = Vec::with_capacity(total as usize);
396 segment_ids.push(format!("{doc_uri}#cof-seg-0"));
397 for i in 0..body_count {
398 segment_ids.push(format!("{doc_uri}#cof-seg-{}", i + 1));
399 }
400
401 let mut segments = Vec::new();
402
403 {
405 let meta = CofSegmentMeta {
406 index: 0,
407 total,
408 id: segment_ids[0].clone(),
409 prev_id: None,
410 next_id: segment_ids.get(1).cloned(),
411 is_index: true,
412 };
413 let html = wrap_document(doc_uri, title, &meta, &index_body, style);
414 let char_count = html.len();
415 segments.push(CofSegment {
416 index: 0,
417 total,
418 id: meta.id,
419 title: format!("{title} — index"),
420 html,
421 char_count,
422 approx_tokens: approx_tokens(char_count),
423 unit_frags: Vec::new(),
424 is_index: true,
425 });
426 }
427
428 for (seg_i, (idxs, _)) in packs.iter().enumerate() {
430 let mut body = String::new();
431 let mut frags = Vec::new();
432 let mut last_page: Option<u32> = None;
434 for &ui in idxs {
435 let u = &units[ui];
436 if let Some(p) = u.page {
437 if last_page != Some(p) {
438 last_page = Some(p);
439 body.push_str(&format!(
440 "<div class=\"meta\" typeof=\"cof:Page\" property=\"cof:hasPage\" \
441 resource=\"{doc_uri}#page-{p}\" content=\"{p}\">\
442 page <span property=\"cof:pageNumber\">{p}</span></div>\n"
443 ));
444 }
445 }
446 body.push_str(&render_unit_fragment(doc_uri, u, style));
447 frags.push(u.frag.clone());
448 }
449 let idx = (seg_i as u32) + 1;
450 let meta = CofSegmentMeta {
451 index: idx,
452 total,
453 id: segment_ids[seg_i + 1].clone(),
454 prev_id: Some(segment_ids[seg_i].clone()),
455 next_id: segment_ids.get(seg_i + 2).cloned(),
456 is_index: false,
457 };
458 let html = wrap_document(doc_uri, title, &meta, &body, style);
459 let char_count = html.len();
460 segments.push(CofSegment {
461 index: idx,
462 total,
463 id: meta.id,
464 title: format!("{title} — segment {idx}"),
465 html,
466 char_count,
467 approx_tokens: approx_tokens(char_count),
468 unit_frags: frags,
469 is_index: false,
470 });
471 }
472
473 let total_chars: usize = segments.iter().map(|s| s.char_count).sum();
474 CofPackage {
475 document_uri: doc_uri.into(),
476 title: title.into(),
477 profile: COF_PROFILE.into(),
478 segment_max_chars: max_chars,
479 total_chars,
480 total_approx_tokens: approx_tokens(total_chars),
481 segments,
482 }
483}
484
485pub fn render_cof_document(
487 doc_uri: &str,
488 title: &str,
489 units: &[ContextUnit],
490 style: CofStyle,
491) -> String {
492 let pkg = build_cof_package(doc_uri, title, units, usize::MAX / 4, style);
493 if pkg.segments.len() >= 2 {
495 pkg.segments[1].html.clone()
496 } else {
497 pkg.segments[0].html.clone()
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 fn sample_units(n: usize, body_len: usize) -> Vec<ContextUnit> {
506 (0..n)
507 .map(|i| ContextUnit {
508 frag: format!("sec-{i}"),
509 kind: "section".into(),
510 label: format!("{i} Sample section"),
511 text: format!(
512 "Section {i}. The controller shall not process personal data without consent. {}",
513 "word ".repeat(body_len / 5)
514 ),
515 page: Some((i as u32 / 3) + 1),
516 parent: None,
517 })
518 .collect()
519 }
520
521 #[test]
522 fn cof_profile_and_rdfa_attributes_present() {
523 let units = sample_units(2, 40);
524 let html = render_cof_document("urn:doc:t", "Test Act", &units, CofStyle::AgentLean);
525 assert!(html.contains(COF_PROFILE));
526 assert!(
527 html.contains("typeof=\"cof:Document\"")
528 || html.contains("typeof=\"cml:Concept cof:Section\"")
529 );
530 assert!(html.contains("cof:hasSection") || html.contains("property=\"cof:hasSection\""));
531 assert!(html.contains("values:originalText") || html.contains("cof:Claim"));
532 assert!(html.contains("cml:Proposed"));
533 assert!(!html.contains("<style>")); }
535
536 #[test]
537 fn large_doc_segments_for_token_budget() {
538 let units = sample_units(20, 2000);
539 let pkg = build_cof_package("urn:doc:big", "Big Act", &units, 8_000, CofStyle::AgentLean);
540 assert!(
541 pkg.segments.len() >= 3,
542 "index + ≥2 body segs, got {}",
543 pkg.segments.len()
544 );
545 assert!(pkg.segments[0].is_index);
546 for s in pkg.segments.iter().filter(|s| !s.is_index) {
548 assert!(
549 s.char_count < 40_000,
550 "segment {} too large: {}",
551 s.index,
552 s.char_count
553 );
554 assert!(!s.unit_frags.is_empty());
555 assert!(s.html.contains("cof-segment"));
556 assert!(s.html.contains("cof:prevSegment") || s.index == 1);
557 }
558 assert!(pkg.segments[0].html.contains("Index"));
560 assert!(pkg.segments[0].html.len() < pkg.total_chars);
561 }
562
563 #[test]
564 fn dual_surface_has_minimal_css() {
565 let units = sample_units(1, 20);
566 let html = render_cof_document("urn:doc:h", "Human", &units, CofStyle::DualSurface);
567 assert!(html.contains("<style>"));
568 assert!(html.contains("cml:Proposed"));
569 }
570}