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 }
345 const auto type = entity.value("entity_type", "entity");
346 const auto label = preferred_names.contains(id)
347 ? preferred_names.at(id)
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 }
366 const auto label = preferred_names.contains(id)
367 ? preferred_names.at(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 };
392 }
393 for (const auto& concept_record :
395 if (!concept_record.is_object()) {
396 continue;
397 }
399 const auto label = preferred_names.contains(id)
400 ? preferred_names.at(id)
401 :
entity_label(concept_record, concept_record.value(
"slug",
id));
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 }
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 }
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 }
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) {
494 if (!link.is_object()) {
495 continue;
496 }
498 link, "assertion_id", assertion_namespace
499 );
500 const auto evidence_id
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 :
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
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();
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 :
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
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
580 );
581 }
582 for (const auto& assertion :
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
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();
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 }
614 edges, from, to, "credit:" + credit.value("role", "contributor"),
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
626 const auto source_id
628 if (!evidence_id || !source_id) {
629 continue;
630 }
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",
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 :
754 continue;
755 }
757 nlohmann::ordered_json attributes = nlohmann::ordered_json::object();
759 &&
candidate.at(
"attributes").is_object()) {
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 }
781 nodes,
782 { { "node_id", id },
783 { "node_type", "research_candidate" },
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 }
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 }
815 nodes,
816 { { "node_id", id },
817 { "node_type", "candidate_work" },
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}
std::string edge_id(std::string_view from, std::string_view to, std::string_view type, std::string_view source_id)
void upsert_node(std::map< std::string, nlohmann::ordered_json, std::less<> > &nodes, nlohmann::ordered_json node)
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())
std::string entity_label(const nlohmann::json &value, const std::string &fallback)
const json * field(const json &object, const std::string_view key, const std::string_view path, validation_result &result)