306 const nlohmann::json& product_export,
307 const nlohmann::json& candidate_export, std::string product_snapshot_id,
308 std::string candidate_snapshot_id
310 if (product_snapshot_id.empty() || candidate_snapshot_id.empty()) {
311 throw std::invalid_argument(
312 "viewer projection requires snapshot identifiers"
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;
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()) {
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;
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>();
340 for (
const auto& entity : array_or_empty(product_export,
"entities")) {
341 if (!entity.is_object()) {
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);
352 {
"node_type", type },
354 {
"graph_domain",
"product" },
356 { {
"origin",
"human_authored" },
357 {
"snapshot_id", product_snapshot_for_provenance } } },
358 {
"attributes", { {
"noncanonical",
false } } } }
361 for (
const auto& work : array_or_empty(product_export,
"works")) {
362 if (!work.is_object()) {
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 },
373 if (work.contains(
"year_start")
374 && work.at(
"year_start").is_number_integer()) {
375 attributes[
"year_start"] = work.at(
"year_start");
377 if (work.contains(
"year_end")
378 && work.at(
"year_end").is_number_integer()) {
379 attributes[
"year_end"] = work.at(
"year_end");
381 nlohmann::ordered_json node {
383 {
"node_type",
"work" },
385 {
"graph_domain",
"product" },
387 { {
"origin",
"human_authored" },
388 {
"snapshot_id", product_snapshot_for_provenance } } },
389 {
"attributes", std::move(attributes) },
391 upsert_node(nodes, std::move(node));
393 for (
const auto& concept_record :
394 array_or_empty(product_export,
"concepts")) {
395 if (!concept_record.is_object()) {
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));
405 {
"node_type",
"concept" },
407 {
"graph_domain",
"product" },
409 { {
"origin",
"human_authored" },
410 {
"snapshot_id", product_snapshot_for_provenance } } },
413 concept_record.value(
"concept_type",
"concept") },
414 {
"noncanonical",
false } } } }
418 for (
const auto& source : array_or_empty(product_export,
"sources")) {
419 if (!source.is_object()) {
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()) {
429 if (source.contains(field) && source.at(field).is_string()) {
430 label = source.at(field).get<std::string>();
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);
447 {
"node_type",
"source" },
448 {
"label", std::move(label) },
449 {
"graph_domain",
"product" },
451 { {
"origin",
"human_authored" },
452 {
"snapshot_id", product_snapshot_for_provenance } } },
453 {
"attributes", std::move(attributes) } }
456 for (
const auto& evidence : array_or_empty(product_export,
"evidence")) {
457 if (!evidence.is_object()) {
460 const auto id = identifier(evidence,
"evidence");
461 std::string label = evidence.value(
"exact_quote", std::string {});
462 if (label.size() > 120U) {
467 label =
"Evidence " + id;
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);
480 {
"node_type",
"evidence" },
481 {
"label", std::move(label) },
482 {
"graph_domain",
"product" },
484 { {
"origin",
"human_authored" },
485 {
"snapshot_id", product_snapshot_for_provenance } } },
486 {
"attributes", std::move(attributes) } }
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()) {
497 const auto assertion_id = projection_identifier(
498 link,
"assertion_id", assertion_namespace
500 const auto evidence_id
501 = projection_identifier(link,
"evidence_id",
"evidence");
502 if (!assertion_id || !evidence_id) {
505 auto& values = evidence_by_assertion[*assertion_id];
506 if (!values.is_array()) {
507 values = nlohmann::json::array();
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&>()
515 if (!already_present) {
516 values.push_back(*evidence_id);
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");
524 nlohmann::ordered_json edges = nlohmann::ordered_json::array();
527 std::map<std::string, std::set<std::string, std::less<>>, std::less<>>,
529 concept_work_assertions;
530 for (
const auto& assertion :
531 array_or_empty(product_export,
"work_concepts")) {
532 if (!assertion.is_object()) {
535 const auto from = assertion.value(
"work_id",
"");
536 const auto to = assertion.value(
"concept_id",
"");
537 if (from.empty() || to.empty()) {
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);
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();
555 assertion.value(
"relation_type",
"associated_with"), assertion_id,
556 product_snapshot_for_provenance, evidence
559 for (
const auto& relation :
560 array_or_empty(product_export,
"concept_relations")) {
561 if (!relation.is_object()) {
564 const auto from = relation.value(
"subject_concept_id",
"");
565 const auto to = relation.value(
"object_concept_id",
"");
566 if (from.empty() || to.empty()) {
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();
578 edges, from, to, relation.value(
"relation_type",
"related_to"),
579 assertion_id, product_snapshot_for_provenance, evidence
582 for (
const auto& assertion :
583 array_or_empty(product_export,
"parent_guide_assertions")) {
584 if (!assertion.is_object()) {
587 const auto from = assertion.value(
"work_id",
"");
588 const auto to = assertion.value(
"concept_id",
"");
589 if (from.empty() || to.empty()) {
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();
600 "parent_guide:" + assertion.value(
"category",
"guidance"),
601 assertion_id, product_snapshot_for_provenance, evidence
604 for (
const auto& credit : array_or_empty(product_export,
"credits")) {
605 if (!credit.is_object()) {
608 const auto from = credit.value(
"agent_id",
"");
609 const auto to = credit.value(
"work_id",
"");
610 if (from.empty() || to.empty()) {
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
620 for (
const auto& evidence : array_or_empty(product_export,
"evidence")) {
621 if (!evidence.is_object()) {
624 const auto evidence_id
625 = projection_identifier(evidence,
"id",
"evidence");
627 = projection_identifier(evidence,
"source_id",
"source");
628 if (!evidence_id || !source_id) {
632 edges, *source_id, *evidence_id,
"documents_evidence",
633 "source-link:" + *evidence_id, product_snapshot_for_provenance
637 struct similarity_basis {
638 std::set<std::string, std::less<>> concept_ids;
639 std::set<std::string, std::less<>> assertion_ids;
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);
651 for (std::size_t left = 0; left < work_ids.size(); ++left) {
652 for (std::size_t right = left + 1; right < work_ids.size();
654 auto& basis = similarity_by_work_pair[{ work_ids[left],
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()
661 basis.assertion_ids.insert(
662 work_assertions.at(work_ids[right]).begin(),
663 work_assertions.at(work_ids[right]).end()
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);
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);
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.";
689 from, to,
"derived_similarity",
690 "ariadne-viewer-similarity-v1"
694 {
"edge_type",
"derived_similarity" },
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 } } },
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) } } } }
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
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;
726 edge_id(from, to,
"derived_chronological",
"ariadne-v1") },
729 {
"edge_type",
"derived_chronological" },
731 { {
"origin",
"derived_projection" },
732 {
"snapshot_id", product_snapshot_for_provenance },
733 {
"algorithm_version",
"ariadne-viewer-projection-v1" },
735 "Ariadne connects adjacent dated works for navigation; "
737 "is not a human-authored influence or genre "
740 { {
"derived",
true }, {
"visual_style",
"red_path" } } } }
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);
751 for (
const auto& candidate :
752 array_or_empty(candidate_export,
"candidates")) {
753 if (!candidate.is_object()) {
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");
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");
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()) {
778 = candidate.at(
"selection_reasons").at(0).get<std::string>();
783 {
"node_type",
"research_candidate" },
784 {
"label", entity_label(candidate, id) },
785 {
"graph_domain",
"candidate" },
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) } }
794 for (
const auto& work : array_or_empty(candidate_export,
"works")) {
795 if (!work.is_object()) {
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");
803 attributes[
"noncanonical"] =
true;
804 attributes[
"soft_guidance"] =
true;
805 if (work.contains(
"candidate_id")) {
806 attributes[
"candidate_id"] = work.at(
"candidate_id");
808 if (work.contains(
"external_id")) {
809 attributes[
"external_id"] = work.at(
"external_id");
811 if (work.contains(
"year")) {
812 attributes[
"year"] = work.at(
"year");
817 {
"node_type",
"candidate_work" },
818 {
"label", entity_label(work, id) },
819 {
"graph_domain",
"candidate" },
821 { {
"origin",
"derived_external" },
822 {
"snapshot_id", candidate_snapshot_for_provenance },
823 {
"algorithm_version", candidate_algorithm_version },
825 "Noncanonical external work offered as research "
827 {
"attributes", std::move(attributes) } }
830 for (
const auto& relation : array_or_empty(candidate_export,
"relations")) {
831 if (!relation.is_object()) {
834 const auto from = relation.value(
"source_id",
"");
835 const auto to = relation.value(
"target_id",
"");
836 if (from.empty() || to.empty()) {
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()) {
844 = relation.at(
"provenance").value(
"explanation", explanation);
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");
851 attributes[
"derived"] =
true;
852 attributes[
"soft_guidance"] =
true;
853 if (relation.contains(
"weight")) {
854 attributes[
"weight"] = relation.at(
"weight");
856 const auto relation_id = relation.value(
857 "relation_id", edge_id(from, to,
"suggestion",
"v1")
860 { {
"edge_id", relation_id },
864 relation.value(
"relation_type",
"research_suggestion") },
866 { {
"origin",
"derived_external" },
867 {
"snapshot_id", candidate_snapshot_for_provenance },
869 nlohmann::ordered_json::array({ relation_id }) },
870 {
"algorithm_version", candidate_algorithm_version },
871 {
"explanation", std::move(explanation) } } },
872 {
"attributes", std::move(attributes) } }
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) },
885 for (
auto& [id, node] : nodes) {
886 static_cast<
void>(id);
887 projection[
"nodes"].push_back(std::move(node));
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&>();
895 projection[
"edges"] = std::move(sorted_edges);
896 projection[
"projection_id"]
902 const nlohmann::json& product_export, std::string product_snapshot_id
904 if (product_snapshot_id.empty()) {
905 throw std::invalid_argument(
906 "viewer catalog requires a product snapshot identifier"
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()) {
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;
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>();
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;
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
941 const auto found = source.find(std::string(source_key));
942 destination[std::string(destination_key)]
943 = found == source.end() ? nlohmann::json(
nullptr) : *found;
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()) {
954 const auto id = concept_row.at(
"entity_id").get<std::string>();
957 {
"label", label_for(id, concept_row.value(
"slug", id)) },
959 concept_row.value(
"concept_type",
"concept") },
960 {
"slug", concept_row.value(
"slug", id) },
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()) {
970 const auto id = agent.at(
"entity_id").get<std::string>();
973 {
"label", label_for(id, id) },
974 {
"agentType", agent.value(
"agent_type",
"person") },
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()) {
984 const auto id = work.at(
"entity_id").get<std::string>();
985 nlohmann::ordered_json item {
987 {
"label", label_for(id, id) },
988 {
"medium", work.value(
"medium",
"unknown") },
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" },
1002 copy_field(item, destination, work, source);
1004 if (item.at(
"productionInfo").is_string()) {
1006 = item.at(
"productionInfo").get_ref<
const std::string&>();
1008 item[
"productionInfo"] =
nullptr;
1011 item[
"productionInfo"] = nlohmann::json::parse(raw);
1012 }
catch (
const nlohmann::json::exception&) {
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));
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()) {
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));
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()) {
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));
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()) {
1070 nlohmann::ordered_json item {
1072 projection_identifier(assertion,
"id",
"parent-guide")
1074 {
"conceptId", concept_id },
1075 {
"label", concept_it->second.at(
"label") },
1076 {
"category", assertion.value(
"category",
"guidance") },
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" },
1088 copy_field(item, destination, assertion, source);
1090 work->second[
"advisories"].push_back(std::move(item));
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()) {
1099 nlohmann::ordered_json item {
1100 {
"type", measurement.value(
"measurement_type",
"unknown") },
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));
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()) {
1114 nlohmann::ordered_json item {
1115 {
"scheme", identifier.value(
"scheme",
"unknown") },
1116 {
"value", identifier.value(
"value",
"") },
1118 copy_field(item,
"url", identifier,
"canonical_url");
1119 work->second[
"identifiers"].push_back(std::move(item));
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()) {
1128 const auto id = manifestation.value(
"entity_id",
"");
1129 nlohmann::ordered_json item {
1132 manifestation.value(
"manifestation_type",
"manifestation") },
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;
1144 work->second[
"manifestations"].push_back(std::move(item));
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()) {
1153 nlohmann::ordered_json item {
1154 {
"type", fact.value(
"fact_type",
"unknown") },
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;
1170 item[
"isEstimate"] = estimate;
1171 work->second[
"financialFacts"].push_back(std::move(item));
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));
1180 {
"formatVersion", 1 },
1181 {
"productSnapshotId", std::move(product_snapshot_id) },
1182 {
"works", std::move(work_array) },
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
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"
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");
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()
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()
1271 if (!entry.is_regular_file()) {
1275 = std::filesystem::relative(entry.path(), asset_root)
1277 if (relative.empty() || relative ==
"data/catalog.json") {
1280 content.emplace(relative, read_file(entry.path()));
1282 if (!content.contains(
"index.html")) {
1283 throw std::runtime_error(
"compiled viewer has no index.html");
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") },
1295 content[
"build-info.json"] = build_info.dump(2) +
"\n";
1297 std::string digest_input;
1298 for (
const auto& [path, bytes] : content) {
1299 digest_input += path +
"\n" + crypto::sha256(bytes) +
"\n";
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);
1312 for (
const auto& [relative, bytes] : content) {
1313 write_file(staging_root / relative, bytes);
1315 std::filesystem::rename(staging_root, bundle_root);
1317 std::error_code ignored;
1318 std::filesystem::remove_all(staging_root, ignored);
1322 if (!std::filesystem::is_directory(bundle_root)) {
1323 throw std::runtime_error(
1324 "viewer bundle identity is not a directory"
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"
1336 if (entry.is_regular_file()) {
1338 = std::filesystem::relative(entry.path(), bundle_root)
1340 if (!content.contains(relative)) {
1341 throw std::runtime_error(
1342 "existing viewer bundle contains an unexpected file"
1345 observed.insert(relative);
1348 if (observed.size() != content.size()) {
1349 throw std::runtime_error(
"existing viewer bundle is incomplete");
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"
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" },
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) },
1380 std::uintmax_t total_bytes = 0;
1381 for (
const auto& [relative, bytes] : content) {
1382 static_cast<
void>(relative);
1383 total_bytes += bytes.size();
1385 manifest[
"bundle"][
"byte_length"] = total_bytes;
1386 const auto validation = arachnespace::contracts::validate(
1389 if (!validation.valid()) {
1390 throw std::invalid_argument(
1391 "generated site_bundle_v1 contract is invalid"
1394 const auto pointer = site_root /
"active.json";
1395 write_file(pointer, manifest.dump(2) +
"\n");