Arachne 1.0
Arachne - the perpetual stitcher of Wikidata entities.
Loading...
Searching...
No Matches
viewer.cpp
Go to the documentation of this file.
1#include "ariadne/viewer.hpp"
2
3#include "arachne/contracts.hpp"
4#include "arachne/crypto.hpp"
5
6#include <algorithm>
7#include <array>
8#include <cstdint>
9#include <fstream>
10#include <map>
11#include <optional>
12#include <set>
13#include <stdexcept>
14#include <string_view>
15#include <utility>
16#include <vector>
17
18namespace arachne::ariadne {
19namespace {
20
21 const nlohmann::json&
22 array_or_empty(const nlohmann::json& document, std::string_view field) {
23 static const nlohmann::json empty = nlohmann::json::array();
24 if (!document.is_object() || !document.contains(field)) {
25 return empty;
26 }
27 if (!document.at(field).is_array()) {
28 throw std::invalid_argument(
29 std::string(field) + " must be an array"
30 );
31 }
32 return document.at(field);
33 }
34
35 std::optional<std::string> projection_identifier(
36 const nlohmann::json& value, std::string_view field,
37 std::string_view integer_namespace
38 ) {
39 const auto found = value.find(std::string(field));
40 if (found == value.end() || found->is_null()) {
41 return std::nullopt;
42 }
43 if (found->is_string()) {
44 const auto id = found->get<std::string>();
45 return id.empty() ? std::nullopt
46 : std::optional<std::string> { id };
47 }
48 if (found->is_number_integer() && !integer_namespace.empty()) {
49 const auto id = found->get<std::int64_t>();
50 if (id <= 0) {
51 throw std::invalid_argument(
52 std::string(field)
53 + " must be a positive internal database identifier"
54 );
55 }
56 return std::string(integer_namespace) + ":" + std::to_string(id);
57 }
58 throw std::invalid_argument(
59 std::string(field)
60 + " must be a string identifier or a namespaced integer"
61 );
62 }
63
64 std::optional<std::string> projection_identifier(
65 const nlohmann::json& value, std::string_view integer_namespace
66 ) {
67 if (value.is_null()) {
68 return std::nullopt;
69 }
70 if (value.is_string()) {
71 const auto id = value.get<std::string>();
72 return id.empty() ? std::nullopt
73 : std::optional<std::string> { id };
74 }
75 if (value.is_number_integer() && !integer_namespace.empty()) {
76 const auto id = value.get<std::int64_t>();
77 if (id <= 0) {
78 throw std::invalid_argument(
79 "internal database identifier must be positive"
80 );
81 }
82 return std::string(integer_namespace) + ":" + std::to_string(id);
83 }
84 throw std::invalid_argument(
85 "projection identifier must be a string or namespaced integer"
86 );
87 }
88
89 std::string identifier(
90 const nlohmann::json& value, std::string_view integer_namespace = {}
91 ) {
92 for (const auto* field :
93 { "id", "entity_id", "candidate_id", "work_id" }) {
94 if (const auto id
95 = projection_identifier(value, field, integer_namespace)) {
96 return *id;
97 }
98 }
99 throw std::invalid_argument("viewer record has no stable identifier");
100 }
101
102 std::string
103 entity_label(const nlohmann::json& value, const std::string& fallback) {
104 for (const auto* field : { "label", "name", "title", "value" }) {
105 if (value.contains(field) && value.at(field).is_string()
106 && !value.at(field).get_ref<const std::string&>().empty()) {
107 return value.at(field).get<std::string>();
108 }
109 }
110 return fallback;
111 }
112
116 ) {
117 const auto id = node.at("node_id").get<std::string>();
118 if (const auto found = nodes.find(id); found == nodes.end()) {
119 nodes.emplace(id, std::move(node));
120 } else {
121 if (node.contains("attributes")
122 && node.at("attributes").is_object()) {
123 auto& attributes = found->second["attributes"];
124 if (!attributes.is_object()) {
125 attributes = nlohmann::ordered_json::object();
126 }
127 for (auto iterator = node.at("attributes").begin();
128 iterator != node.at("attributes").end(); ++iterator) {
129 attributes[iterator.key()] = iterator.value();
130 }
131 }
132 for (auto iterator = node.begin(); iterator != node.end();
133 ++iterator) {
134 if (iterator.key() != "attributes"
135 && (!found->second.contains(iterator.key())
136 || found->second.at(iterator.key()).is_null())) {
137 found->second[iterator.key()] = iterator.value();
138 }
139 }
140 }
141 }
142
143 std::string edge_id(
144 std::string_view from, std::string_view to, std::string_view type,
145 std::string_view source_id
146 ) {
147 return "edge_"
149 std::string(from) + "\n" + std::string(to) + "\n"
150 + std::string(type) + "\n" + std::string(source_id)
151 )
152 .substr(0, 24);
153 }
154
156 nlohmann::ordered_json& edges, std::string from, std::string to,
157 std::string type, std::string assertion_id, std::string snapshot_id,
158 const nlohmann::json& evidence = nlohmann::json::array()
159 ) {
160 nlohmann::ordered_json source_ids = nlohmann::ordered_json::array();
161 nlohmann::ordered_json evidence_ids = nlohmann::ordered_json::array();
162 std::set<std::string, std::less<>> unique_sources;
163 std::set<std::string, std::less<>> unique_evidence;
164 if (!assertion_id.empty()) {
165 unique_sources.insert(assertion_id);
166 }
167 if (evidence.is_array()) {
168 for (const auto& value : evidence) {
169 if (const auto id = projection_identifier(value, "evidence")) {
170 unique_sources.insert(*id);
171 unique_evidence.insert(*id);
172 }
173 }
174 }
175 for (const auto& source : unique_sources) {
176 source_ids.push_back(source);
177 }
178 for (const auto& evidence_id : unique_evidence) {
179 evidence_ids.push_back(evidence_id);
180 }
181 nlohmann::ordered_json provenance {
182 { "origin", "human_authored" },
183 { "snapshot_id", std::move(snapshot_id) },
184 { "source_ids", std::move(source_ids) },
185 { "explanation",
186 "Accepted human-authored research relation with "
187 "assertion-specific evidence." },
188 };
189 edges.push_back(
190 { { "edge_id", edge_id(from, to, type, assertion_id) },
191 { "source", std::move(from) },
192 { "target", std::move(to) },
193 { "edge_type", std::move(type) },
194 { "provenance", std::move(provenance) },
195 { "attributes",
196 { { "derived", false },
197 { "assertion_id", assertion_id },
198 { "evidence", std::move(evidence_ids) } } } }
199 );
200 }
201
202 std::string read_file(const std::filesystem::path& path) {
203 std::ifstream input(path, std::ios::binary);
204 if (!input) {
205 throw std::runtime_error(
206 "cannot read viewer template: " + path.string()
207 );
208 }
209 return { std::istreambuf_iterator<char>(input),
210 std::istreambuf_iterator<char>() };
211 }
212
213 void
214 write_file(const std::filesystem::path& path, std::string_view content) {
215 std::filesystem::create_directories(path.parent_path());
216 const auto temporary = path.string() + ".part";
217 {
218 std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
219 if (!output) {
220 throw std::runtime_error(
221 "cannot write viewer artifact: " + path.string()
222 );
223 }
224 output.write(
225 content.data(), static_cast<std::streamsize>(content.size())
226 );
227 if (!output) {
228 throw std::runtime_error(
229 "cannot finish viewer artifact: " + path.string()
230 );
231 }
232 }
233 std::filesystem::rename(temporary, path);
234 }
235
237 const std::filesystem::path& path, std::string_view content
238 ) {
239 if (path.empty() || path.filename().empty()) {
240 throw std::invalid_argument(
241 "viewer artifact destination must be a file"
242 );
243 }
244 if (!path.parent_path().empty()) {
245 std::filesystem::create_directories(path.parent_path());
246 }
247 if (std::filesystem::exists(path)) {
248 if (!std::filesystem::is_regular_file(path)
249 || std::filesystem::file_size(path) != content.size()
250 || crypto::sha256_file(path) != crypto::sha256(content)) {
251 throw std::runtime_error(
252 "viewer artifact destination already contains different "
253 "bytes"
254 );
255 }
256 return;
257 }
258 auto staging = path;
259 staging += ".part";
260 if (std::filesystem::exists(staging)) {
261 throw std::runtime_error(
262 "viewer artifact staging file already exists"
263 );
264 }
265 try {
266 {
267 std::ofstream output(
268 staging, std::ios::binary | std::ios::trunc
269 );
270 if (!output) {
271 throw std::runtime_error("cannot create viewer artifact");
272 }
273 output.write(
274 content.data(), static_cast<std::streamsize>(content.size())
275 );
276 output.close();
277 if (!output) {
278 throw std::runtime_error("cannot finish viewer artifact");
279 }
280 }
281 std::filesystem::create_hard_link(staging, path);
282 std::filesystem::remove(staging);
283 } catch (...) {
284 std::error_code ignored;
285 std::filesystem::remove(staging, ignored);
286 throw;
287 }
288 }
289
290 void validate_site_root(const std::filesystem::path& root) {
291 if (root.empty() || root == root.root_path()) {
292 throw std::invalid_argument("site root must be a scoped directory");
293 }
294 for (const auto& component : root) {
295 if (component == "..") {
296 throw std::invalid_argument(
297 "site root must not contain parent traversal"
298 );
299 }
300 }
301 }
302
303} // namespace
304
305nlohmann::ordered_json viewer_builder::project(
306 const nlohmann::json& product_export,
307 const nlohmann::json& candidate_export, std::string product_snapshot_id,
308 std::string candidate_snapshot_id
309) {
310 if (product_snapshot_id.empty() || candidate_snapshot_id.empty()) {
311 throw std::invalid_argument(
312 "viewer projection requires snapshot identifiers"
313 );
314 }
315 std::map<std::string, nlohmann::ordered_json, std::less<>> nodes;
316 std::map<std::string, std::string, std::less<>> preferred_names;
317 const auto product_snapshot_for_provenance = product_snapshot_id;
318 const auto candidate_snapshot_for_provenance = candidate_snapshot_id;
319
320 for (const auto& name : array_or_empty(product_export, "names")) {
321 if (!name.is_object() || !name.contains("entity_id")
322 || !name.at("entity_id").is_string() || !name.contains("value")
323 || !name.at("value").is_string()) {
324 continue;
325 }
326 bool preferred = false;
327 if (const auto value = name.find("is_preferred"); value != name.end()) {
328 if (value->is_boolean()) {
329 preferred = value->get<bool>();
330 } else if (value->is_number_integer()) {
331 preferred = value->get<int>() != 0;
332 }
333 }
334 const auto entity_id = name.at("entity_id").get<std::string>();
335 if (preferred || !preferred_names.contains(entity_id)) {
336 preferred_names[entity_id] = name.at("value").get<std::string>();
337 }
338 }
339
340 for (const auto& entity : array_or_empty(product_export, "entities")) {
341 if (!entity.is_object()) {
342 continue;
343 }
344 const auto id = identifier(entity);
345 const auto type = entity.value("entity_type", "entity");
346 const auto label = preferred_names.contains(id)
347 ? preferred_names.at(id)
348 : entity_label(entity, id);
349 upsert_node(
350 nodes,
351 { { "node_id", id },
352 { "node_type", type },
353 { "label", label },
354 { "graph_domain", "product" },
355 { "provenance",
356 { { "origin", "human_authored" },
357 { "snapshot_id", product_snapshot_for_provenance } } },
358 { "attributes", { { "noncanonical", false } } } }
359 );
360 }
361 for (const auto& work : array_or_empty(product_export, "works")) {
362 if (!work.is_object()) {
363 continue;
364 }
365 const auto id = identifier(work);
366 const auto label = preferred_names.contains(id)
367 ? preferred_names.at(id)
368 : entity_label(work, id);
369 nlohmann::ordered_json attributes {
370 { "medium", work.value("medium", "unknown") },
371 { "noncanonical", false },
372 };
373 if (work.contains("year_start")
374 && work.at("year_start").is_number_integer()) {
375 attributes["year_start"] = work.at("year_start");
376 }
377 if (work.contains("year_end")
378 && work.at("year_end").is_number_integer()) {
379 attributes["year_end"] = work.at("year_end");
380 }
381 nlohmann::ordered_json node {
382 { "node_id", id },
383 { "node_type", "work" },
384 { "label", label },
385 { "graph_domain", "product" },
386 { "provenance",
387 { { "origin", "human_authored" },
388 { "snapshot_id", product_snapshot_for_provenance } } },
389 { "attributes", std::move(attributes) },
390 };
391 upsert_node(nodes, std::move(node));
392 }
393 for (const auto& concept_record :
394 array_or_empty(product_export, "concepts")) {
395 if (!concept_record.is_object()) {
396 continue;
397 }
398 const auto id = identifier(concept_record);
399 const auto label = preferred_names.contains(id)
400 ? preferred_names.at(id)
401 : entity_label(concept_record, concept_record.value("slug", id));
402 upsert_node(
403 nodes,
404 { { "node_id", id },
405 { "node_type", "concept" },
406 { "label", label },
407 { "graph_domain", "product" },
408 { "provenance",
409 { { "origin", "human_authored" },
410 { "snapshot_id", product_snapshot_for_provenance } } },
411 { "attributes",
412 { { "concept_type",
413 concept_record.value("concept_type", "concept") },
414 { "noncanonical", false } } } }
415 );
416 }
417
418 for (const auto& source : array_or_empty(product_export, "sources")) {
419 if (!source.is_object()) {
420 continue;
421 }
422 const auto id = identifier(source, "source");
423 std::string label = entity_label(source, "");
424 for (const auto* field :
425 { "bibliography_text", "url", "doi", "isbn" }) {
426 if (!label.empty()) {
427 break;
428 }
429 if (source.contains(field) && source.at(field).is_string()) {
430 label = source.at(field).get<std::string>();
431 }
432 }
433 if (label.empty()) {
434 label = id;
435 }
436 nlohmann::ordered_json attributes = nlohmann::ordered_json::object();
437 for (const auto* field :
438 { "source_type", "author_text", "publisher", "publication_date",
439 "url", "doi", "isbn", "language_code" }) {
440 if (source.contains(field) && !source.at(field).is_null()) {
441 attributes[field] = source.at(field);
442 }
443 }
444 upsert_node(
445 nodes,
446 { { "node_id", id },
447 { "node_type", "source" },
448 { "label", std::move(label) },
449 { "graph_domain", "product" },
450 { "provenance",
451 { { "origin", "human_authored" },
452 { "snapshot_id", product_snapshot_for_provenance } } },
453 { "attributes", std::move(attributes) } }
454 );
455 }
456 for (const auto& evidence : array_or_empty(product_export, "evidence")) {
457 if (!evidence.is_object()) {
458 continue;
459 }
460 const auto id = identifier(evidence, "evidence");
461 std::string label = evidence.value("exact_quote", std::string {});
462 if (label.size() > 120U) {
463 label.resize(117U);
464 label += "...";
465 }
466 if (label.empty()) {
467 label = "Evidence " + id;
468 }
469 nlohmann::ordered_json attributes = nlohmann::ordered_json::object();
470 for (const auto* field :
471 { "exact_quote", "quote_language", "quote_translation",
472 "locator_json", "stance" }) {
473 if (evidence.contains(field) && !evidence.at(field).is_null()) {
474 attributes[field] = evidence.at(field);
475 }
476 }
477 upsert_node(
478 nodes,
479 { { "node_id", id },
480 { "node_type", "evidence" },
481 { "label", std::move(label) },
482 { "graph_domain", "product" },
483 { "provenance",
484 { { "origin", "human_authored" },
485 { "snapshot_id", product_snapshot_for_provenance } } },
486 { "attributes", std::move(attributes) } }
487 );
488 }
489
490 std::map<std::string, nlohmann::json, std::less<>> evidence_by_assertion;
491 const auto collect_evidence_links
492 = [&](std::string_view field, std::string_view assertion_namespace) {
493 for (const auto& link : array_or_empty(product_export, field)) {
494 if (!link.is_object()) {
495 continue;
496 }
497 const auto assertion_id = projection_identifier(
498 link, "assertion_id", assertion_namespace
499 );
500 const auto evidence_id
501 = projection_identifier(link, "evidence_id", "evidence");
502 if (!assertion_id || !evidence_id) {
503 continue;
504 }
505 auto& values = evidence_by_assertion[*assertion_id];
506 if (!values.is_array()) {
507 values = nlohmann::json::array();
508 }
509 const bool already_present
510 = std::ranges::any_of(values, [&](const auto& value) {
511 return value.is_string()
512 && value.template get_ref<const std::string&>()
513 == *evidence_id;
514 });
515 if (!already_present) {
516 values.push_back(*evidence_id);
517 }
518 }
519 };
520 collect_evidence_links("work_concept_evidence", "work-concept");
521 collect_evidence_links("concept_relation_evidence", "concept-relation");
522 collect_evidence_links("parent_guide_evidence", "parent-guide");
523
524 nlohmann::ordered_json edges = nlohmann::ordered_json::array();
525 std::map<
526 std::string,
527 std::map<std::string, std::set<std::string, std::less<>>, std::less<>>,
528 std::less<>>
529 concept_work_assertions;
530 for (const auto& assertion :
531 array_or_empty(product_export, "work_concepts")) {
532 if (!assertion.is_object()) {
533 continue;
534 }
535 const auto from = assertion.value("work_id", "");
536 const auto to = assertion.value("concept_id", "");
537 if (from.empty() || to.empty()) {
538 continue;
539 }
540 const auto assertion_id
541 = projection_identifier(assertion, "id", "work-concept")
542 .value_or(edge_id(from, to, "assertion", "missing"));
543 if (nodes.contains(from) && nodes.contains(to)
544 && nodes.at(from).value("node_type", "") == "work"
545 && nodes.at(to).value("node_type", "") == "concept") {
546 concept_work_assertions[to][from].insert(assertion_id);
547 }
548 const auto evidence = assertion.contains("evidence")
549 ? assertion.at("evidence")
550 : evidence_by_assertion.contains(assertion_id)
551 ? evidence_by_assertion.at(assertion_id)
552 : nlohmann::json::array();
553 append_human_edge(
554 edges, from, to,
555 assertion.value("relation_type", "associated_with"), assertion_id,
556 product_snapshot_for_provenance, evidence
557 );
558 }
559 for (const auto& relation :
560 array_or_empty(product_export, "concept_relations")) {
561 if (!relation.is_object()) {
562 continue;
563 }
564 const auto from = relation.value("subject_concept_id", "");
565 const auto to = relation.value("object_concept_id", "");
566 if (from.empty() || to.empty()) {
567 continue;
568 }
569 const auto assertion_id
570 = projection_identifier(relation, "id", "concept-relation")
571 .value_or(edge_id(from, to, "concept_relation", "missing"));
572 const auto evidence = relation.contains("evidence")
573 ? relation.at("evidence")
574 : evidence_by_assertion.contains(assertion_id)
575 ? evidence_by_assertion.at(assertion_id)
576 : nlohmann::json::array();
577 append_human_edge(
578 edges, from, to, relation.value("relation_type", "related_to"),
579 assertion_id, product_snapshot_for_provenance, evidence
580 );
581 }
582 for (const auto& assertion :
583 array_or_empty(product_export, "parent_guide_assertions")) {
584 if (!assertion.is_object()) {
585 continue;
586 }
587 const auto from = assertion.value("work_id", "");
588 const auto to = assertion.value("concept_id", "");
589 if (from.empty() || to.empty()) {
590 continue;
591 }
592 const auto assertion_id
593 = projection_identifier(assertion, "id", "parent-guide")
594 .value_or(edge_id(from, to, "parent_guide", "missing"));
595 const auto evidence = evidence_by_assertion.contains(assertion_id)
596 ? evidence_by_assertion.at(assertion_id)
597 : nlohmann::json::array();
598 append_human_edge(
599 edges, from, to,
600 "parent_guide:" + assertion.value("category", "guidance"),
601 assertion_id, product_snapshot_for_provenance, evidence
602 );
603 }
604 for (const auto& credit : array_or_empty(product_export, "credits")) {
605 if (!credit.is_object()) {
606 continue;
607 }
608 const auto from = credit.value("agent_id", "");
609 const auto to = credit.value("work_id", "");
610 if (from.empty() || to.empty()) {
611 continue;
612 }
613 append_human_edge(
614 edges, from, to, "credit:" + credit.value("role", "contributor"),
615 projection_identifier(credit, "id", "credit")
616 .value_or(edge_id(from, to, "credit", "missing")),
617 product_snapshot_for_provenance
618 );
619 }
620 for (const auto& evidence : array_or_empty(product_export, "evidence")) {
621 if (!evidence.is_object()) {
622 continue;
623 }
624 const auto evidence_id
625 = projection_identifier(evidence, "id", "evidence");
626 const auto source_id
627 = projection_identifier(evidence, "source_id", "source");
628 if (!evidence_id || !source_id) {
629 continue;
630 }
631 append_human_edge(
632 edges, *source_id, *evidence_id, "documents_evidence",
633 "source-link:" + *evidence_id, product_snapshot_for_provenance
634 );
635 }
636
637 struct similarity_basis {
638 std::set<std::string, std::less<>> concept_ids;
639 std::set<std::string, std::less<>> assertion_ids;
640 };
641
642 std::map<std::pair<std::string, std::string>, similarity_basis, std::less<>>
643 similarity_by_work_pair;
644 for (const auto& [concept_id, work_assertions] : concept_work_assertions) {
645 std::vector<std::string> work_ids;
646 work_ids.reserve(work_assertions.size());
647 for (const auto& [work_id, assertion_ids] : work_assertions) {
648 static_cast<void>(assertion_ids);
649 work_ids.push_back(work_id);
650 }
651 for (std::size_t left = 0; left < work_ids.size(); ++left) {
652 for (std::size_t right = left + 1; right < work_ids.size();
653 ++right) {
654 auto& basis = similarity_by_work_pair[{ work_ids[left],
655 work_ids[right] }];
656 basis.concept_ids.insert(concept_id);
657 basis.assertion_ids.insert(
658 work_assertions.at(work_ids[left]).begin(),
659 work_assertions.at(work_ids[left]).end()
660 );
661 basis.assertion_ids.insert(
662 work_assertions.at(work_ids[right]).begin(),
663 work_assertions.at(work_ids[right]).end()
664 );
665 }
666 }
667 }
668 for (const auto& [work_pair, basis] : similarity_by_work_pair) {
669 const auto& [from, to] = work_pair;
670 nlohmann::ordered_json concept_ids = nlohmann::ordered_json::array();
671 for (const auto& concept_id : basis.concept_ids) {
672 concept_ids.push_back(concept_id);
673 }
674 nlohmann::ordered_json assertion_ids = nlohmann::ordered_json::array();
675 for (const auto& assertion_id : basis.assertion_ids) {
676 assertion_ids.push_back(assertion_id);
677 }
678 const auto count = basis.concept_ids.size();
679 const auto explanation
680 = "Ariadne connects these works because accepted human-authored "
681 "assertions attach both to "
682 + std::to_string(count) + " shared concept"
683 + (count == 1U ? "" : "s")
684 + "; this derived similarity path is for navigation and is not a "
685 "human-authored relation.";
686 edges.push_back(
687 { { "edge_id",
688 edge_id(
689 from, to, "derived_similarity",
690 "ariadne-viewer-similarity-v1"
691 ) },
692 { "source", from },
693 { "target", to },
694 { "edge_type", "derived_similarity" },
695 { "provenance",
696 { { "origin", "derived_projection" },
697 { "snapshot_id", product_snapshot_for_provenance },
698 { "source_ids", std::move(assertion_ids) },
699 { "algorithm_version", "ariadne-viewer-similarity-v1" },
700 { "explanation", explanation } } },
701 { "attributes",
702 { { "derived", true },
703 { "visual_style", "woven_path" },
704 { "similarity_basis", "shared_human_concepts" },
705 { "shared_concept_count", count },
706 { "shared_concept_ids", std::move(concept_ids) } } } }
707 );
708 }
709
710 std::vector<std::pair<int, std::string>> chronology;
711 for (const auto& [id, node] : nodes) {
712 if (node.value("node_type", "") == "work" && node.contains("attributes")
713 && node.at("attributes").contains("year_start")
714 && node.at("attributes").at("year_start").is_number_integer()) {
715 chronology.emplace_back(
716 node.at("attributes").at("year_start").get<int>(), id
717 );
718 }
719 }
720 std::ranges::sort(chronology);
721 for (std::size_t index = 1; index < chronology.size(); ++index) {
722 const auto& from = chronology[index - 1].second;
723 const auto& to = chronology[index].second;
724 edges.push_back(
725 { { "edge_id",
726 edge_id(from, to, "derived_chronological", "ariadne-v1") },
727 { "source", from },
728 { "target", to },
729 { "edge_type", "derived_chronological" },
730 { "provenance",
731 { { "origin", "derived_projection" },
732 { "snapshot_id", product_snapshot_for_provenance },
733 { "algorithm_version", "ariadne-viewer-projection-v1" },
734 { "explanation",
735 "Ariadne connects adjacent dated works for navigation; "
736 "this "
737 "is not a human-authored influence or genre "
738 "assertion." } } },
739 { "attributes",
740 { { "derived", true }, { "visual_style", "red_path" } } } }
741 );
742 }
743
744 std::string candidate_algorithm_version = "candidate-materialization-v1";
745 if (candidate_export.is_object() && candidate_export.contains("algorithm")
746 && candidate_export.at("algorithm").is_object()) {
747 candidate_algorithm_version
748 = candidate_export.at("algorithm")
749 .value("version", candidate_algorithm_version);
750 }
751 for (const auto& candidate :
752 array_or_empty(candidate_export, "candidates")) {
753 if (!candidate.is_object()) {
754 continue;
755 }
756 const auto id = identifier(candidate);
757 nlohmann::ordered_json attributes = nlohmann::ordered_json::object();
758 if (candidate.contains("attributes")
759 && candidate.at("attributes").is_object()) {
760 attributes = candidate.at("attributes");
761 }
762 attributes["noncanonical"] = true;
763 attributes["soft_guidance"] = true;
764 attributes["rank"] = candidate.value("rank", 0);
765 attributes["coverage"] = candidate.value("coverage", 0.0);
766 attributes["group_id"] = candidate.value("group_id", "unassigned");
767 attributes["kind"] = candidate.value("kind", "candidate");
768 if (candidate.contains("selection_reasons")) {
769 attributes["selection_reasons"] = candidate.at("selection_reasons");
770 }
771 std::string explanation
772 = "Noncanonical candidate selected from external data.";
773 if (candidate.contains("selection_reasons")
774 && candidate.at("selection_reasons").is_array()
775 && !candidate.at("selection_reasons").empty()
776 && candidate.at("selection_reasons").at(0).is_string()) {
777 explanation
778 = candidate.at("selection_reasons").at(0).get<std::string>();
779 }
780 upsert_node(
781 nodes,
782 { { "node_id", id },
783 { "node_type", "research_candidate" },
784 { "label", entity_label(candidate, id) },
785 { "graph_domain", "candidate" },
786 { "provenance",
787 { { "origin", "derived_external" },
788 { "snapshot_id", candidate_snapshot_for_provenance },
789 { "algorithm_version", candidate_algorithm_version },
790 { "explanation", std::move(explanation) } } },
791 { "attributes", std::move(attributes) } }
792 );
793 }
794 for (const auto& work : array_or_empty(candidate_export, "works")) {
795 if (!work.is_object()) {
796 continue;
797 }
798 const auto id = identifier(work);
799 nlohmann::ordered_json attributes = nlohmann::ordered_json::object();
800 if (work.contains("attributes") && work.at("attributes").is_object()) {
801 attributes = work.at("attributes");
802 }
803 attributes["noncanonical"] = true;
804 attributes["soft_guidance"] = true;
805 if (work.contains("candidate_id")) {
806 attributes["candidate_id"] = work.at("candidate_id");
807 }
808 if (work.contains("external_id")) {
809 attributes["external_id"] = work.at("external_id");
810 }
811 if (work.contains("year")) {
812 attributes["year"] = work.at("year");
813 }
814 upsert_node(
815 nodes,
816 { { "node_id", id },
817 { "node_type", "candidate_work" },
818 { "label", entity_label(work, id) },
819 { "graph_domain", "candidate" },
820 { "provenance",
821 { { "origin", "derived_external" },
822 { "snapshot_id", candidate_snapshot_for_provenance },
823 { "algorithm_version", candidate_algorithm_version },
824 { "explanation",
825 "Noncanonical external work offered as research "
826 "guidance." } } },
827 { "attributes", std::move(attributes) } }
828 );
829 }
830 for (const auto& relation : array_or_empty(candidate_export, "relations")) {
831 if (!relation.is_object()) {
832 continue;
833 }
834 const auto from = relation.value("source_id", "");
835 const auto to = relation.value("target_id", "");
836 if (from.empty() || to.empty()) {
837 continue;
838 }
839 std::string explanation = "Noncanonical research suggestion; no work "
840 "or creator is reserved.";
841 if (relation.contains("provenance")
842 && relation.at("provenance").is_object()) {
843 explanation
844 = relation.at("provenance").value("explanation", explanation);
845 }
846 nlohmann::ordered_json attributes = nlohmann::ordered_json::object();
847 if (relation.contains("attributes")
848 && relation.at("attributes").is_object()) {
849 attributes = relation.at("attributes");
850 }
851 attributes["derived"] = true;
852 attributes["soft_guidance"] = true;
853 if (relation.contains("weight")) {
854 attributes["weight"] = relation.at("weight");
855 }
856 const auto relation_id = relation.value(
857 "relation_id", edge_id(from, to, "suggestion", "v1")
858 );
859 edges.push_back(
860 { { "edge_id", relation_id },
861 { "source", from },
862 { "target", to },
863 { "edge_type",
864 relation.value("relation_type", "research_suggestion") },
865 { "provenance",
866 { { "origin", "derived_external" },
867 { "snapshot_id", candidate_snapshot_for_provenance },
868 { "source_ids",
869 nlohmann::ordered_json::array({ relation_id }) },
870 { "algorithm_version", candidate_algorithm_version },
871 { "explanation", std::move(explanation) } } },
872 { "attributes", std::move(attributes) } }
873 );
874 }
875
876 nlohmann::ordered_json projection {
877 { "artifact_type", "viewer_projection_data_v1" },
878 { "format_version", 1 },
879 { "projection_version", "ariadne-viewer-projection-v1" },
880 { "product_snapshot_id", std::move(product_snapshot_id) },
881 { "candidate_snapshot_id", std::move(candidate_snapshot_id) },
882 { "nodes", nlohmann::ordered_json::array() },
883 { "edges", std::move(edges) },
884 };
885 for (auto& [id, node] : nodes) {
886 static_cast<void>(id);
887 projection["nodes"].push_back(std::move(node));
888 }
889 auto sorted_edges
890 = projection["edges"].get<std::vector<nlohmann::ordered_json>>();
891 std::ranges::sort(sorted_edges, [](const auto& left, const auto& right) {
892 return left.at("edge_id").template get_ref<const std::string&>()
893 < right.at("edge_id").template get_ref<const std::string&>();
894 });
895 projection["edges"] = std::move(sorted_edges);
896 projection["projection_id"]
897 = "projection_" + crypto::sha256(projection.dump()).substr(0, 32);
898 return projection;
899}
900
901nlohmann::ordered_json viewer_builder::catalog(
902 const nlohmann::json& product_export, std::string product_snapshot_id
903) {
904 if (product_snapshot_id.empty()) {
905 throw std::invalid_argument(
906 "viewer catalog requires a product snapshot identifier"
907 );
908 }
909
910 std::map<std::string, std::string, std::less<>> preferred_names;
911 for (const auto& name : array_or_empty(product_export, "names")) {
912 if (!name.is_object() || !name.contains("entity_id")
913 || !name.at("entity_id").is_string() || !name.contains("value")
914 || !name.at("value").is_string()) {
915 continue;
916 }
917 bool preferred = false;
918 if (const auto value = name.find("is_preferred"); value != name.end()) {
919 if (value->is_boolean()) {
920 preferred = value->get<bool>();
921 } else if (value->is_number_integer()) {
922 preferred = value->get<int>() != 0;
923 }
924 }
925 const auto id = name.at("entity_id").get<std::string>();
926 if (preferred || !preferred_names.contains(id)) {
927 preferred_names[id] = name.at("value").get<std::string>();
928 }
929 }
930
931 const auto label_for = [&](const std::string& id, const std::string& fallback) {
932 const auto found = preferred_names.find(id);
933 return found == preferred_names.end() ? fallback : found->second;
934 };
935 const auto copy_field = [](
936 nlohmann::ordered_json& destination,
937 const std::string_view destination_key,
938 const nlohmann::json& source,
939 const std::string_view source_key
940 ) {
941 const auto found = source.find(std::string(source_key));
942 destination[std::string(destination_key)]
943 = found == source.end() ? nlohmann::json(nullptr) : *found;
944 };
945
946 std::map<std::string, nlohmann::ordered_json, std::less<>> concepts;
947 for (const auto& concept_row :
948 array_or_empty(product_export, "concepts")) {
949 if (!concept_row.is_object()
950 || !concept_row.contains("entity_id")
951 || !concept_row.at("entity_id").is_string()) {
952 continue;
953 }
954 const auto id = concept_row.at("entity_id").get<std::string>();
955 concepts[id] = {
956 { "id", id },
957 { "label", label_for(id, concept_row.value("slug", id)) },
958 { "conceptType",
959 concept_row.value("concept_type", "concept") },
960 { "slug", concept_row.value("slug", id) },
961 };
962 }
963
964 std::map<std::string, nlohmann::ordered_json, std::less<>> agents;
965 for (const auto& agent : array_or_empty(product_export, "agents")) {
966 if (!agent.is_object() || !agent.contains("entity_id")
967 || !agent.at("entity_id").is_string()) {
968 continue;
969 }
970 const auto id = agent.at("entity_id").get<std::string>();
971 agents[id] = {
972 { "id", id },
973 { "label", label_for(id, id) },
974 { "agentType", agent.value("agent_type", "person") },
975 };
976 }
977
978 std::map<std::string, nlohmann::ordered_json, std::less<>> works;
979 for (const auto& work : array_or_empty(product_export, "works")) {
980 if (!work.is_object() || !work.contains("entity_id")
981 || !work.at("entity_id").is_string()) {
982 continue;
983 }
984 const auto id = work.at("entity_id").get<std::string>();
985 nlohmann::ordered_json item {
986 { "id", id },
987 { "label", label_for(id, id) },
988 { "medium", work.value("medium", "unknown") },
989 };
990 for (const auto [destination, source] :
991 std::array<std::pair<std::string_view, std::string_view>, 9> {
992 std::pair { "yearStart", "year_start" },
993 std::pair { "yearEnd", "year_end" },
994 std::pair { "datePrecision", "date_precision" },
995 std::pair { "dateStartText", "date_start_text" },
996 std::pair { "dateEndText", "date_end_text" },
997 std::pair { "dateQualifier", "date_qualifier" },
998 std::pair { "languageCode", "language_code" },
999 std::pair { "countryCode", "country_code" },
1000 std::pair { "productionInfo", "production_info_json" },
1001 }) {
1002 copy_field(item, destination, work, source);
1003 }
1004 if (item.at("productionInfo").is_string()) {
1005 const auto& raw
1006 = item.at("productionInfo").get_ref<const std::string&>();
1007 if (raw.empty()) {
1008 item["productionInfo"] = nullptr;
1009 } else {
1010 try {
1011 item["productionInfo"] = nlohmann::json::parse(raw);
1012 } catch (const nlohmann::json::exception&) {
1013 // Preserve malformed legacy text.
1014 }
1015 }
1016 }
1017 item["concepts"] = nlohmann::ordered_json::array();
1018 item["contributors"] = nlohmann::ordered_json::array();
1019 item["advisories"] = nlohmann::ordered_json::array();
1020 item["measurements"] = nlohmann::ordered_json::array();
1021 item["identifiers"] = nlohmann::ordered_json::array();
1022 item["manifestations"] = nlohmann::ordered_json::array();
1023 item["financialFacts"] = nlohmann::ordered_json::array();
1024 works.emplace(id, std::move(item));
1025 }
1026
1027 for (const auto& assignment :
1028 array_or_empty(product_export, "work_concepts")) {
1029 const auto work_id = assignment.value("work_id", "");
1030 const auto concept_id = assignment.value("concept_id", "");
1031 const auto work = works.find(work_id);
1032 const auto concept_it = concepts.find(concept_id);
1033 if (work == works.end() || concept_it == concepts.end()) {
1034 continue;
1035 }
1036 auto item = concept_it->second;
1037 item["relationType"]
1038 = assignment.value("relation_type", "associated_with");
1039 copy_field(item, "centrality", assignment, "centrality");
1040 copy_field(item, "historicalRole", assignment, "historical_role");
1041 copy_field(item, "confidence", assignment, "confidence");
1042 work->second["concepts"].push_back(std::move(item));
1043 }
1044
1045 for (const auto& credit : array_or_empty(product_export, "credits")) {
1046 const auto work_id = credit.value("work_id", "");
1047 const auto agent_id = credit.value("agent_id", "");
1048 const auto work = works.find(work_id);
1049 const auto agent = agents.find(agent_id);
1050 if (work == works.end() || agent == agents.end()) {
1051 continue;
1052 }
1053 auto item = agent->second;
1054 item["role"] = credit.value("role", "contributor");
1055 copy_field(item, "order", credit, "credit_order");
1056 item["importance"] = credit.value("importance", "supporting");
1057 copy_field(item, "creditedAs", credit, "credited_as");
1058 work->second["contributors"].push_back(std::move(item));
1059 }
1060
1061 for (const auto& assertion :
1062 array_or_empty(product_export, "parent_guide_assertions")) {
1063 const auto work_id = assertion.value("work_id", "");
1064 const auto concept_id = assertion.value("concept_id", "");
1065 const auto work = works.find(work_id);
1066 const auto concept_it = concepts.find(concept_id);
1067 if (work == works.end() || concept_it == concepts.end()) {
1068 continue;
1069 }
1070 nlohmann::ordered_json item {
1071 { "id",
1072 projection_identifier(assertion, "id", "parent-guide")
1073 .value_or("") },
1074 { "conceptId", concept_id },
1075 { "label", concept_it->second.at("label") },
1076 { "category", assertion.value("category", "guidance") },
1077 };
1078 for (const auto [destination, source] :
1079 std::array<std::pair<std::string_view, std::string_view>, 7> {
1080 std::pair { "intensity", "intensity" },
1081 std::pair { "explicitness", "explicitness" },
1082 std::pair { "frequency", "frequency" },
1083 std::pair { "centrality", "centrality" },
1084 std::pair { "realism", "realism" },
1085 std::pair { "spoilerLevel", "spoiler_level" },
1086 std::pair { "confidence", "confidence" },
1087 }) {
1088 copy_field(item, destination, assertion, source);
1089 }
1090 work->second["advisories"].push_back(std::move(item));
1091 }
1092
1093 for (const auto& measurement :
1094 array_or_empty(product_export, "measurements")) {
1095 const auto work = works.find(measurement.value("entity_id", ""));
1096 if (work == works.end()) {
1097 continue;
1098 }
1099 nlohmann::ordered_json item {
1100 { "type", measurement.value("measurement_type", "unknown") },
1101 };
1102 copy_field(item, "value", measurement, "value");
1103 copy_field(item, "unit", measurement, "unit");
1104 copy_field(item, "qualifier", measurement, "qualifier");
1105 work->second["measurements"].push_back(std::move(item));
1106 }
1107
1108 for (const auto& identifier :
1109 array_or_empty(product_export, "external_ids")) {
1110 const auto work = works.find(identifier.value("entity_id", ""));
1111 if (work == works.end()) {
1112 continue;
1113 }
1114 nlohmann::ordered_json item {
1115 { "scheme", identifier.value("scheme", "unknown") },
1116 { "value", identifier.value("value", "") },
1117 };
1118 copy_field(item, "url", identifier, "canonical_url");
1119 work->second["identifiers"].push_back(std::move(item));
1120 }
1121
1122 for (const auto& manifestation :
1123 array_or_empty(product_export, "manifestations")) {
1124 const auto work = works.find(manifestation.value("work_id", ""));
1125 if (work == works.end()) {
1126 continue;
1127 }
1128 const auto id = manifestation.value("entity_id", "");
1129 nlohmann::ordered_json item {
1130 { "id", id },
1131 { "type",
1132 manifestation.value("manifestation_type", "manifestation") },
1133 };
1134 copy_field(item, "releaseYear", manifestation, "release_year");
1135 copy_field(item, "regionCode", manifestation, "region_code");
1136 copy_field(item, "languageCode", manifestation, "language_code");
1137 copy_field(item, "label", manifestation, "label");
1138 if (item.at("label").is_null() && !id.empty()) {
1139 const auto found = preferred_names.find(id);
1140 if (found != preferred_names.end()) {
1141 item["label"] = found->second;
1142 }
1143 }
1144 work->second["manifestations"].push_back(std::move(item));
1145 }
1146
1147 for (const auto& fact :
1148 array_or_empty(product_export, "financial_facts")) {
1149 const auto work = works.find(fact.value("work_id", ""));
1150 if (work == works.end()) {
1151 continue;
1152 }
1153 nlohmann::ordered_json item {
1154 { "type", fact.value("fact_type", "unknown") },
1155 };
1156 copy_field(item, "amountMin", fact, "amount_min");
1157 copy_field(item, "amountMax", fact, "amount_max");
1158 copy_field(item, "currencyCode", fact, "currency_code");
1159 copy_field(item, "valueYear", fact, "value_year");
1160 copy_field(item, "confidence", fact, "confidence");
1161 bool estimate = false;
1162 if (const auto value = fact.find("is_estimate");
1163 value != fact.end()) {
1164 if (value->is_boolean()) {
1165 estimate = value->get<bool>();
1166 } else if (value->is_number_integer()) {
1167 estimate = value->get<int>() != 0;
1168 }
1169 }
1170 item["isEstimate"] = estimate;
1171 work->second["financialFacts"].push_back(std::move(item));
1172 }
1173
1174 nlohmann::ordered_json work_array = nlohmann::ordered_json::array();
1175 for (auto& [id, work] : works) {
1176 static_cast<void>(id);
1177 work_array.push_back(std::move(work));
1178 }
1179 return {
1180 { "formatVersion", 1 },
1181 { "productSnapshotId", std::move(product_snapshot_id) },
1182 { "works", std::move(work_array) },
1183 };
1184}
1185
1186nlohmann::ordered_json viewer_builder::write_projection(
1187 const nlohmann::json& projection_data,
1188 const std::filesystem::path& destination, std::string storage_ref,
1189 std::string settings_sha256, std::string generated_at
1190) {
1191 if (!projection_data.is_object()
1192 || projection_data.value("artifact_type", "")
1193 != "viewer_projection_data_v1"
1194 || projection_data.value("format_version", 0) != 1) {
1195 throw std::invalid_argument(
1196 "projection publication requires viewer_projection_data_v1"
1197 );
1198 }
1199 const std::string bytes = projection_data.dump(2) + "\n";
1200 nlohmann::ordered_json contract {
1201 { "contract", "viewer_projection_v1" },
1202 { "format_version", 1 },
1203 { "projection_id", projection_data.at("projection_id") },
1204 { "product_snapshot_id", projection_data.at("product_snapshot_id") },
1205 { "candidate_snapshot_id",
1206 projection_data.at("candidate_snapshot_id") },
1207 { "projection_version", projection_data.at("projection_version") },
1208 { "settings_sha256", std::move(settings_sha256) },
1209 { "projection",
1210 { { "storage_ref", std::move(storage_ref) },
1211 { "sha256", crypto::sha256(bytes) },
1212 { "byte_length", bytes.size() },
1213 { "media_type", "application/json" } } },
1214 { "edge_semantics",
1215 { { "human_type", "human_assertion" },
1216 { "derived_types",
1217 { "derived_chronological", "derived_similarity",
1218 "research_suggestion" } } } },
1219 { "generated_at", std::move(generated_at) },
1220 };
1221 const auto validation = arachnespace::contracts::validate(
1223 );
1224 if (!validation.valid()) {
1225 throw std::invalid_argument(
1226 "generated viewer_projection_v1 contract is invalid"
1227 );
1228 }
1229 write_immutable_file(destination, bytes);
1230 return contract;
1231}
1232
1233nlohmann::ordered_json viewer_builder::build_site(
1234 const nlohmann::json& projection, const nlohmann::json& catalog_data,
1235 const std::filesystem::path& template_root,
1236 const std::filesystem::path& site_root, std::string generated_at
1237) {
1238 validate_site_root(site_root);
1239 if (!projection.is_object()
1240 || projection.value("artifact_type", "") != "viewer_projection_data_v1"
1241 || projection.value("format_version", 0) != 1) {
1242 throw std::invalid_argument(
1243 "site build requires viewer_projection_data_v1"
1244 );
1245 }
1246 if (!catalog_data.is_object()
1247 || catalog_data.value("formatVersion", 0) != 1
1248 || !catalog_data.contains("works")
1249 || !catalog_data.at("works").is_array()) {
1250 throw std::invalid_argument("site build requires viewer catalog v1");
1251 }
1252
1253 const auto asset_root = template_root / "dist";
1254 if (!std::filesystem::is_directory(asset_root)) {
1255 throw std::runtime_error(
1256 "compiled viewer assets are missing; run npm ci && npm run build in "
1257 + template_root.string()
1258 );
1259 }
1260
1261 std::map<std::string, std::string, std::less<>> content;
1262 for (const auto& entry :
1263 std::filesystem::recursive_directory_iterator(asset_root)) {
1264 if (entry.is_symlink()
1265 || (!entry.is_directory() && !entry.is_regular_file())) {
1266 throw std::runtime_error(
1267 "compiled viewer contains an unsafe entry: "
1268 + entry.path().string()
1269 );
1270 }
1271 if (!entry.is_regular_file()) {
1272 continue;
1273 }
1274 const auto relative
1275 = std::filesystem::relative(entry.path(), asset_root)
1276 .generic_string();
1277 if (relative.empty() || relative == "data/catalog.json") {
1278 continue;
1279 }
1280 content.emplace(relative, read_file(entry.path()));
1281 }
1282 if (!content.contains("index.html")) {
1283 throw std::runtime_error("compiled viewer has no index.html");
1284 }
1285
1286 content["data/catalog.json"] = catalog_data.dump() + "\n";
1287 const nlohmann::ordered_json build_info {
1288 { "artifact_format", "site_build_info_v1" },
1289 { "format_version", 1 },
1290 { "product_snapshot_id", projection.at("product_snapshot_id") },
1291 { "candidate_snapshot_id", projection.at("candidate_snapshot_id") },
1292 { "projection_version", projection.at("projection_version") },
1293 { "projection_id", projection.at("projection_id") },
1294 };
1295 content["build-info.json"] = build_info.dump(2) + "\n";
1296
1297 std::string digest_input;
1298 for (const auto& [path, bytes] : content) {
1299 digest_input += path + "\n" + crypto::sha256(bytes) + "\n";
1300 }
1301 const auto bundle_hash = crypto::sha256(digest_input);
1302 const auto bundle_id = "site_" + bundle_hash.substr(0, 32);
1303 const auto staging_root = site_root / (".staging-" + bundle_id);
1304 const auto bundles_root = site_root / "bundles";
1305 const auto bundle_root = bundles_root / bundle_id;
1306 std::filesystem::create_directories(bundles_root);
1307 if (!std::filesystem::exists(bundle_root)) {
1308 if (std::filesystem::exists(staging_root)) {
1309 std::filesystem::remove_all(staging_root);
1310 }
1311 try {
1312 for (const auto& [relative, bytes] : content) {
1313 write_file(staging_root / relative, bytes);
1314 }
1315 std::filesystem::rename(staging_root, bundle_root);
1316 } catch (...) {
1317 std::error_code ignored;
1318 std::filesystem::remove_all(staging_root, ignored);
1319 throw;
1320 }
1321 } else {
1322 if (!std::filesystem::is_directory(bundle_root)) {
1323 throw std::runtime_error(
1324 "viewer bundle identity is not a directory"
1325 );
1326 }
1327 std::set<std::string, std::less<>> observed;
1328 for (const auto& entry :
1329 std::filesystem::recursive_directory_iterator(bundle_root)) {
1330 if (entry.is_symlink()
1331 || (!entry.is_directory() && !entry.is_regular_file())) {
1332 throw std::runtime_error(
1333 "existing viewer bundle contains an unsafe entry"
1334 );
1335 }
1336 if (entry.is_regular_file()) {
1337 const auto relative
1338 = std::filesystem::relative(entry.path(), bundle_root)
1339 .generic_string();
1340 if (!content.contains(relative)) {
1341 throw std::runtime_error(
1342 "existing viewer bundle contains an unexpected file"
1343 );
1344 }
1345 observed.insert(relative);
1346 }
1347 }
1348 if (observed.size() != content.size()) {
1349 throw std::runtime_error("existing viewer bundle is incomplete");
1350 }
1351 for (const auto& [relative, bytes] : content) {
1352 const auto path = bundle_root / relative;
1353 if (!std::filesystem::is_regular_file(path)
1354 || std::filesystem::file_size(path) != bytes.size()
1355 || crypto::sha256_file(path) != crypto::sha256(bytes)) {
1356 throw std::runtime_error(
1357 "existing viewer bundle does not match its content identity"
1358 );
1359 }
1360 }
1361 }
1362
1363 nlohmann::ordered_json manifest {
1364 { "contract", "site_bundle_v1" },
1365 { "format_version", 1 },
1366 { "bundle_id", bundle_id },
1367 { "projection_id", projection.at("projection_id") },
1368 { "product_snapshot_id", projection.at("product_snapshot_id") },
1369 { "candidate_snapshot_id", projection.at("candidate_snapshot_id") },
1370 { "viewer_version", "ariadne-react-viewer-2.0.0" },
1371 { "entrypoint", "index.html" },
1372 { "bundle",
1373 { { "storage_ref",
1374 (std::filesystem::path("bundles") / bundle_id).generic_string() },
1375 { "sha256", bundle_hash },
1376 { "byte_length", 0 },
1377 { "media_type", "application/vnd.arachne.static-site" } } },
1378 { "generated_at", std::move(generated_at) },
1379 };
1380 std::uintmax_t total_bytes = 0;
1381 for (const auto& [relative, bytes] : content) {
1382 static_cast<void>(relative);
1383 total_bytes += bytes.size();
1384 }
1385 manifest["bundle"]["byte_length"] = total_bytes;
1386 const auto validation = arachnespace::contracts::validate(
1388 );
1389 if (!validation.valid()) {
1390 throw std::invalid_argument(
1391 "generated site_bundle_v1 contract is invalid"
1392 );
1393 }
1394 const auto pointer = site_root / "active.json";
1395 write_file(pointer, manifest.dump(2) + "\n");
1396 return manifest;
1397}
1398
1399} // namespace arachne::ariadne
std::string read_file(const std::filesystem::path &path)
Definition viewer.cpp:202
void write_file(const std::filesystem::path &path, std::string_view content)
Definition viewer.cpp:214
void write_immutable_file(const std::filesystem::path &path, std::string_view content)
Definition viewer.cpp:236
std::optional< std::string > projection_identifier(const nlohmann::json &value, std::string_view field, std::string_view integer_namespace)
Definition viewer.cpp:35
std::string edge_id(std::string_view from, std::string_view to, std::string_view type, std::string_view source_id)
Definition viewer.cpp:143
std::string identifier(const nlohmann::json &value, std::string_view integer_namespace={})
Definition viewer.cpp:89
void upsert_node(std::map< std::string, nlohmann::ordered_json, std::less<> > &nodes, nlohmann::ordered_json node)
Definition viewer.cpp:113
void append_human_edge(nlohmann::ordered_json &edges, std::string from, std::string to, std::string type, std::string assertion_id, std::string snapshot_id, const nlohmann::json &evidence=nlohmann::json::array())
Definition viewer.cpp:155
std::string entity_label(const nlohmann::json &value, const std::string &fallback)
Definition viewer.cpp:103
void validate_site_root(const std::filesystem::path &root)
Definition viewer.cpp:290
const nlohmann::json & array_or_empty(const nlohmann::json &document, std::string_view field)
Definition viewer.cpp:22
std::optional< std::string > projection_identifier(const nlohmann::json &value, std::string_view integer_namespace)
Definition viewer.cpp:64
std::string sha256(std::span< const std::byte > bytes)
Definition crypto.cpp:209
std::string sha256_file(const std::filesystem::path &path)
Definition crypto.cpp:221