595 sqlite3* db,
const json& control,
const json& payload
597 if (!payload.is_object()) {
598 fail(
"candidate materialization payload must be a JSON object");
602 {
"artifact_type",
"format_version",
"plan_id",
"source_snapshot",
603 "algorithm",
"groups",
"candidates",
"works",
"relations" },
606 if (payload.value(
"artifact_type", std::string {})
607 !=
"research_candidate_graph_materialization_v1"
608 || payload.value(
"format_version", 0) != 1) {
610 "candidate payload must identify "
611 "research_candidate_graph_materialization_v1"
614 const std::string payload_plan_id
615 = require_string(payload,
"plan_id",
"candidate payload");
617 payload_plan_id,
"candidate payload.plan_id"
620 != require_string(control,
"plan_id",
"candidate control")) {
622 "candidate payload plan_id does not match its control contract"
625 for (
const std::string_view key :
626 {
"groups",
"candidates",
"works",
"relations" }) {
627 const auto it = payload.find(std::string(key));
628 if (it == payload.end() || !it->is_array()) {
630 "candidate payload." + std::string(key)
631 +
" must be an array"
635 const auto& source = control.at(
"source_snapshot");
636 const std::string source_id
637 = require_string(source,
"snapshot_id",
"control.source_snapshot");
638 const std::string source_ref
639 = require_string(source,
"storage_ref",
"control.source_snapshot");
640 const std::string source_hash = lowercase(
641 require_string(source,
"sha256",
"control.source_snapshot")
643 const auto& payload_source = payload.at(
"source_snapshot");
645 payload_source, {
"snapshot_id",
"storage_ref",
"sha256" },
646 "payload.source_snapshot"
649 payload_source,
"snapshot_id",
"payload.source_snapshot"
652 payload_source,
"storage_ref",
"payload.source_snapshot"
654 || lowercase(require_string(
655 payload_source,
"sha256",
"payload.source_snapshot"
659 "candidate payload source snapshot does not match its control "
663 const auto& product = control.at(
"product_snapshot");
664 const std::string product_id = require_string(
665 product,
"snapshot_id",
"control.product_snapshot"
667 const std::string product_hash = lowercase(
668 require_string(product,
"sha256",
"control.product_snapshot")
670 const std::string algorithm
671 = require_string(control,
"algorithm_version",
"control");
672 const auto& configuration = control.at(
"configuration");
673 const std::string configuration_hash = lowercase(
674 require_string(configuration,
"sha256",
"control.configuration")
676 const json configuration_values = configuration.at(
"values");
677 if (crypto::sha256(canonical_json(configuration_values))
678 != configuration_hash) {
680 "candidate control configuration hash does not bind its "
681 "configuration values"
684 const auto& payload_algorithm = payload.at(
"algorithm");
686 payload_algorithm, {
"name",
"version",
"configuration_sha256" },
689 const std::string payload_algorithm_name
690 = require_string(payload_algorithm,
"name",
"payload.algorithm");
691 const std::string payload_algorithm_version
692 = require_string(payload_algorithm,
"version",
"payload.algorithm");
694 != payload_algorithm_name +
"-" + payload_algorithm_version) {
696 "candidate payload algorithm identity/version differs from "
700 if (lowercase(require_string(
701 payload_algorithm,
"configuration_sha256",
"payload.algorithm"
703 != configuration_hash) {
705 "candidate payload configuration hash differs from its control"
708 statement graph_info(
710 "INSERT INTO candidate_graph_info"
711 "(singleton,plan_version,source_snapshot_id,source_storage_ref,"
712 "source_snapshot_sha256,product_snapshot_id,product_snapshot_"
714 "algorithm_version,configuration_sha256,configuration_json)"
715 " VALUES(1,1,?1,?2,?3,?4,?5,?6,?7,?8)"
717 graph_info.text(1, source_id);
718 graph_info.text(2, source_ref);
719 graph_info.text(3, source_hash);
720 graph_info.text(4, product_id);
721 graph_info.text(5, product_hash);
722 graph_info.text(6, algorithm);
723 graph_info.text(7, configuration_hash);
724 graph_info.text(8, canonical_json(configuration_values));
727 std::unordered_set<std::string> groups;
728 std::vector<json> ordered_groups;
729 for (
const auto& group : array_or_empty(payload,
"groups",
"payload")) {
730 if (!group.is_object()) {
731 fail(
"payload.groups entries must be objects");
733 ordered_groups.push_back(group);
735 std::ranges::sort(ordered_groups, [](
const json& lhs,
const json& rhs) {
736 return lhs.value(
"group_id", std::string {})
737 < rhs.value(
"group_id", std::string {});
739 std::unordered_map<std::string,
int> expected_group_counts;
740 for (
const auto& group : ordered_groups) {
741 const std::string where
742 =
"groups[" + std::to_string(groups.size()) +
"]";
745 {
"group_id",
"label",
"order",
"candidate_count",
"rationale",
749 const std::string id = require_string(group,
"group_id", where);
750 validate_stable_contract_id(id, where +
".group_id");
751 if (!groups.emplace(id).second) {
752 fail(where +
" duplicates group ID " + id);
754 const int candidate_count
755 = require_integer(group,
"candidate_count", where);
756 if (candidate_count < 0) {
757 fail(where +
".candidate_count must be non-negative");
759 expected_group_counts.emplace(id, candidate_count);
760 json metadata { {
"candidate_count", candidate_count },
762 require_string(group,
"rationale", where) } };
763 if (
const auto attributes = group.find(
"attributes");
764 attributes != group.end()) {
765 if (!attributes->is_object()) {
766 fail(where +
".attributes must be an object");
768 metadata[
"attributes"] = *attributes;
772 "INSERT INTO candidate_groups(id,label,ordinal,metadata_json)"
773 " VALUES(?1,?2,?3,?4)"
776 insert.text(2, require_string(group,
"label", where));
777 insert.integer(3, require_integer(group,
"order", where));
778 insert.text(4, canonical_json(metadata));
782 std::unordered_set<std::string> nodes;
783 std::unordered_set<std::string> candidates;
784 std::unordered_map<std::string, std::string> candidate_groups;
785 std::unordered_map<std::string,
int> actual_group_counts;
786 std::size_t node_index = 0;
787 for (
const auto& node :
788 array_or_empty(payload,
"candidates",
"payload")) {
789 const std::string where
790 =
"candidates[" + std::to_string(node_index++) +
"]";
791 if (!node.is_object()) {
792 fail(where +
" must be an object");
796 {
"candidate_id",
"external_id",
"label",
"kind",
"rank",
797 "coverage",
"group_id",
"selection_reasons",
798 "source_snapshot_id",
"attributes" },
801 const std::string id = require_string(node,
"candidate_id", where);
802 validate_stable_contract_id(id, where +
".candidate_id");
803 if (!nodes.emplace(id).second) {
804 fail(where +
" duplicates candidate node ID " + id);
806 candidates.emplace(id);
807 const std::string group_id
808 = require_string(node,
"group_id", where);
809 if (!groups.contains(group_id)) {
810 fail(where +
".group_id references an unknown group");
812 candidate_groups.emplace(id, group_id);
813 ++actual_group_counts[group_id];
814 const std::string kind = require_string(node,
"kind", where);
815 if (kind !=
"candidate" && kind !=
"grey") {
816 fail(where +
".kind must be candidate or grey");
818 const auto& reasons = node.at(
"selection_reasons");
819 if (!reasons.is_array() || reasons.empty()) {
820 fail(where +
".selection_reasons must be a non-empty array");
822 for (
const auto& reason : reasons) {
823 if (!reason.is_string()
824 || reason.get_ref<
const std::string&>().empty()) {
827 +
".selection_reasons must contain non-empty strings"
831 if (require_string(node,
"source_snapshot_id", where)
833 fail(where +
" references the wrong source snapshot");
835 json source_metadata {
836 {
"source_snapshot_id",
837 require_string(node,
"source_snapshot_id", where) },
838 {
"external_id", require_string(node,
"external_id", where) }
840 if (
const auto attributes = node.find(
"attributes");
841 attributes != node.end()) {
842 if (!attributes->is_object()) {
843 fail(where +
".attributes must be an object");
845 source_metadata[
"attributes"] = *attributes;
849 "INSERT INTO candidate_nodes"
850 "(id,entity_ref,entity_type,label,rank,coverage,group_id,is_"
852 "selection_reason_json,source_metadata_json)"
853 " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"
856 insert.text(2, require_string(node,
"external_id", where));
857 insert.text(3, kind);
858 insert.text(4, require_string(node,
"label", where));
859 insert.integer(5, require_integer(node,
"rank", where));
860 insert.real(6, require_number(node,
"coverage", where));
861 insert.text(7, group_id);
862 insert.integer(8, kind ==
"grey" ? 1 : 0);
863 insert.text(9, canonical_json(reasons));
864 insert.text(10, canonical_json(source_metadata));
867 for (
const auto& [group, expected] : expected_group_counts) {
868 if (actual_group_counts[group] != expected) {
870 "group candidate_count does not match candidate "
877 std::size_t work_index = 0;
878 for (
const auto& work : array_or_empty(payload,
"works",
"payload")) {
879 const std::string where
880 =
"works[" + std::to_string(work_index++) +
"]";
881 if (!work.is_object()) {
882 fail(where +
" must be an object");
886 {
"work_id",
"candidate_id",
"external_id",
"label",
"year",
887 "source_snapshot_id",
"attributes" },
890 const std::string id = require_string(work,
"work_id", where);
891 validate_stable_contract_id(id, where +
".work_id");
892 if (!nodes.emplace(id).second) {
893 fail(where +
" duplicates a candidate/work node ID " + id);
895 const std::string candidate_id
896 = require_string(work,
"candidate_id", where);
897 if (!candidates.contains(candidate_id)) {
898 fail(where +
".candidate_id references an unknown candidate");
900 if (require_string(work,
"source_snapshot_id", where)
902 fail(where +
" references the wrong source snapshot");
904 json metadata { {
"candidate_id", candidate_id },
905 {
"source_snapshot_id", source_id } };
906 if (
const auto year = optional_integer(work,
"year", where)) {
907 metadata[
"year"] = *year;
909 if (
const auto attributes = work.find(
"attributes");
910 attributes != work.end()) {
911 if (!attributes->is_object()) {
912 fail(where +
".attributes must be an object");
914 metadata[
"attributes"] = *attributes;
918 "INSERT INTO candidate_nodes"
919 "(id,entity_ref,entity_type,label,rank,coverage,group_id,is_"
921 "selection_reason_json,source_metadata_json)"
922 " VALUES(?1,?2,'candidate_work',?3,NULL,0,?4,0,'[]',?5)"
925 insert.text(2, require_string(work,
"external_id", where));
926 insert.text(3, require_string(work,
"label", where));
927 insert.text(4, candidate_groups.at(candidate_id));
928 insert.text(5, canonical_json(metadata));
932 std::unordered_set<std::string> edges;
933 std::size_t edge_index = 0;
934 for (
const auto& edge :
935 array_or_empty(payload,
"relations",
"payload")) {
936 const std::string where
937 =
"relations[" + std::to_string(edge_index++) +
"]";
938 if (!edge.is_object()) {
939 fail(where +
" must be an object");
943 {
"relation_id",
"source_id",
"target_id",
"relation_type",
944 "weight",
"provenance",
"attributes" },
947 const std::string id = require_string(edge,
"relation_id", where);
948 validate_stable_contract_id(id, where +
".relation_id");
949 if (!edges.emplace(id).second) {
950 fail(where +
" duplicates candidate edge ID " + id);
952 const std::string subject
953 = require_string(edge,
"source_id", where);
954 const std::string object = require_string(edge,
"target_id", where);
955 if (!nodes.contains(subject) || !nodes.contains(object)) {
956 fail(where +
" references an unknown candidate node");
958 const auto& provenance = edge.at(
"provenance");
961 {
"origin",
"source_snapshot_id",
"algorithm_version",
963 where +
".provenance"
965 if (require_string(provenance,
"origin", where +
".provenance")
966 !=
"algorithmic_external"
968 provenance,
"source_snapshot_id", where +
".provenance"
971 provenance,
"algorithm_version", where +
".provenance"
972 ) != payload_algorithm_version) {
973 fail(where +
".provenance is inconsistent with payload inputs");
975 (
void)require_string(
976 provenance,
"explanation", where +
".provenance"
978 json edge_metadata = provenance;
979 if (
const auto attributes = edge.find(
"attributes");
980 attributes != edge.end()) {
981 if (!attributes->is_object()) {
982 fail(where +
".attributes must be an object");
984 edge_metadata[
"attributes"] = *attributes;
988 "INSERT INTO candidate_edges"
989 "(id,subject_id,relation_type,object_id,weight,metadata_json)"
990 " VALUES(?1,?2,?3,?4,?5,?6)"
993 insert.text(2, subject);
994 insert.text(3, require_string(edge,
"relation_type", where));
995 insert.text(4, object);
996 insert.optional_real(5, optional_number(edge,
"weight", where));
997 insert.text(6, canonical_json(edge_metadata));
1001 const auto& summary = control.at(
"summary");
1002 if (require_integer(summary,
"candidate_count",
"control.summary")
1003 !=
static_cast<
int>(
1004 array_or_empty(payload,
"candidates",
"payload").size()
1006 || require_integer(summary,
"edge_count",
"control.summary")
1007 !=
static_cast<
int>(
1008 array_or_empty(payload,
"relations",
"payload").size()
1010 || require_integer(summary,
"group_count",
"control.summary")
1011 !=
static_cast<
int>(ordered_groups.size())) {
1013 "candidate control summary does not match the resolved payload"
1525 fail(
"candidate snapshot request requires a run_id");
1527 const auto& descriptor = request.plan;
1528 if (!fs::is_regular_file(descriptor.control_contract_path)
1529 || !fs::is_regular_file(descriptor.resolved_plan_payload_path)) {
1530 fail(
"candidate control contract and resolved payload must both exist");
1532 const json control = read_json(descriptor.control_contract_path);
1533 require_valid_contract(
1535 control,
"candidate plan"
1537 const auto& plan_artifact = control.at(
"plan_artifact");
1538 const std::string payload_hash = lowercase(
1539 require_string(plan_artifact,
"sha256",
"control.plan_artifact")
1545 "resolved candidate plan payload hash does not match control "
1549 const auto expected_bytes
1550 = plan_artifact.at(
"byte_length").get<std::uint64_t>();
1551 if (fs::file_size(descriptor.resolved_plan_payload_path)
1552 != expected_bytes) {
1554 "resolved candidate plan payload length does not match control "
1559 = read_candidate_payload(descriptor.resolved_plan_payload_path);
1560 const std::string seed = request.run_id +
"\n"
1561 + require_string(control,
"plan_id",
"candidate control") +
"\n"
1563 staging_guard staging {
1564 .path = make_staging_directory(root_, graph_domain::candidate, seed)
1566 const fs::path database_path = staging.path /
"graph.sqlite";
1568 database db
(database_path
, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE
);
1571 transaction build
(db
);
1572 import_candidate_plan(db
.get(), control, payload);
1576 const fs::path export_path = staging.path /
"graph.jsonl";
1577 const std::string export_hash
1580 remove_staging_sqlite_sidecars(database_path, staging.path);
1581 const std::string snapshot_id =
"candidate_" + export_hash.substr(0, 32);
1582 const json validation_artifact = write_validation_report(
1583 staging.path, graph_domain::candidate, snapshot_id
1585 const std::string database_ref
1586 =
"candidate/snapshots/" + snapshot_id +
"/graph.sqlite";
1587 const std::string export_ref
1588 =
"candidate/snapshots/" + snapshot_id +
"/graph.jsonl";
1589 const json metadata {
1590 {
"contract", candidate_contract },
1591 {
"format_version", 1 },
1592 {
"snapshot_id", snapshot_id },
1593 {
"run_id", request.run_id },
1594 {
"graph_version",
"candidate-schema-v1" },
1595 {
"content_sha256", database_hash },
1598 database_ref, database_hash, fs::file_size(database_path),
1599 "application/vnd.sqlite3"
1603 { { {
"kind",
"candidate-jsonl" },
1606 export_ref, export_hash, fs::file_size(export_path),
1607 "application/x-ndjson"
1610 {
"plan_id", control.at(
"plan_id") },
1611 {
"source_snapshot_id",
1612 control.at(
"source_snapshot").at(
"snapshot_id") },
1613 {
"activated_at", utc_now() },
1614 {
"structural_validation",
1615 { {
"passed",
true }, {
"report", validation_artifact } } },
1617 { {
"org.ninjaro.penelope",
1618 { {
"control_contract_ref",
1619 descriptor.control_contract_path.generic_string() },
1620 {
"control_contract_sha256",
1621 crypto::sha256_file(descriptor.control_contract_path) },
1622 {
"plan_artifact", plan_artifact } } } } },
1624 require_valid_contract(
1627 metadata,
"candidate snapshot metadata"
1630 staging.path /
"metadata.json", canonical_json(metadata) +
"\n"
1632 return finalize_snapshot(
1633 root_, graph_domain::candidate, staging, snapshot_id, database_hash,