139 if (!document.is_object()
140 || document.value(
"artifact_type",
"")
141 !=
"external_candidate_source_graph_v1"
142 || document.value(
"format_version", 0) != 1) {
143 throw std::invalid_argument(
144 "external graph must be external_candidate_source_graph_v1"
149 {
"artifact_type",
"format_version",
"source_snapshot",
"works",
153 if (!document.contains(
"source_snapshot")
154 || !document.at(
"source_snapshot").is_object()) {
155 throw std::invalid_argument(
156 "external graph requires a source_snapshot object"
159 const auto& source = document.at(
"source_snapshot");
161 source, {
"snapshot_id",
"storage_ref",
"sha256" },
162 "external_graph.source_snapshot"
165 result.source_snapshot_id = required_string(
166 source,
"snapshot_id",
"external_graph.source_snapshot"
168 result.source_storage_ref = required_string(
169 source,
"storage_ref",
"external_graph.source_snapshot"
171 result.source_sha256 = required_string(
172 source,
"sha256",
"external_graph.source_snapshot"
176 "external_graph.source_snapshot.snapshot_id"
178 if (result.source_sha256.size() != 64
179 || !std::ranges::all_of(result.source_sha256, [](
const char value) {
180 return (value >=
'0' && value <=
'9')
181 || (value >=
'a' && value <=
'f');
183 throw std::invalid_argument(
184 "source_sha256 must contain 64 hex characters"
187 if (!document.contains(
"works") || !document.at(
"works").is_array()
188 || !document.contains(
"agents") || !document.at(
"agents").is_array()
189 || !document.contains(
"edges")
190 || !document.at(
"edges").is_array()) {
191 throw std::invalid_argument(
192 "external graph requires works, agents, and edges arrays"
196 std::unordered_map<std::string, std::size_t> work_indices;
197 for (
const auto& value : document.at(
"works")) {
198 if (!value.is_object()) {
199 throw std::invalid_argument(
"work entry must be an object");
202 work.id = required_string(value,
"id",
"work");
203 require_only_fields(value, {
"id",
"label",
"covered" },
"work");
204 require_stable_id(work.id,
"work.id");
205 work.label = required_string(value,
"label",
"work");
206 if (!value.contains(
"covered")
207 || !value.at(
"covered").is_boolean()) {
208 throw std::invalid_argument(
"work.covered must be boolean");
210 work.covered = value.at(
"covered").get<
bool>();
211 if (!work_indices.emplace(work.id, result.works.size()).second) {
212 throw std::invalid_argument(
"duplicate work id: " + work.id);
214 result.works.emplace_back(std::move(work));
217 std::unordered_map<std::string, std::size_t> agent_indices;
218 for (
const auto& value : document.at(
"agents")) {
219 if (!value.is_object()) {
220 throw std::invalid_argument(
"agent entry must be an object");
223 agent.id = required_string(value,
"id",
"agent");
224 require_only_fields(value, {
"id",
"label",
"profile" },
"agent");
225 require_stable_id(agent.id,
"agent.id");
226 agent.label = required_string(value,
"label",
"agent");
227 if (!value.contains(
"profile")
228 || !value.at(
"profile").is_object()) {
229 throw std::invalid_argument(
"agent profile must be an object");
231 agent.profile = value.at(
"profile");
232 if (!agent_indices.emplace(agent.id, result.agents.size()).second) {
233 throw std::invalid_argument(
"duplicate agent id: " + agent.id);
235 result.agents.emplace_back(std::move(agent));
238 std::set<std::pair<std::size_t, std::size_t>> unique_edges;
239 for (
const auto& value : document.at(
"edges")) {
240 if (!value.is_object()) {
241 throw std::invalid_argument(
"edge entry must be an object");
243 require_only_fields(value, {
"work_id",
"agent_id" },
"edge");
244 const auto work_id = required_string(value,
"work_id",
"edge");
245 const auto agent_id = required_string(value,
"agent_id",
"edge");
246 const auto work = work_indices.find(work_id);
247 const auto agent = agent_indices.find(agent_id);
248 if (work == work_indices.end() || agent == agent_indices.end()) {
249 throw std::invalid_argument(
250 "candidate edge references an unknown work or agent"
253 if (unique_edges.emplace(agent->second, work->second).second) {
254 result.agents[agent->second].works.push_back(work->second);
257 for (
auto& agent : result.agents) {
259 agent.works, [&](
const auto left,
const auto right) {
261 result.works[left].id, result.works[right].id
308 std::vector<
bool> claimed(graph.works.size(),
false);
309 std::vector<
bool> selected(graph.agents.size(),
false);
310 std::vector<ranked_candidate> result;
311 result.reserve(std::min(configuration
.pool_size, graph.agents.size()));
313 while (result.size() < configuration
.pool_size) {
315 for (std::size_t index = 0; index < graph.agents.size(); ++index) {
316 if (selected[index]) {
322 for (
const auto work_index : graph.agents[index].works) {
323 if (graph.works[work_index].covered) {
324 ++candidate.parsed_children;
325 }
else if (claimed[work_index]) {
326 ++candidate.gray_children;
328 candidate.claimed_works.push_back(work_index);
334 || candidate.claimed_works.empty()) {
337 const auto numerator =
static_cast<std::int64_t>(
340 *
static_cast<std::size_t>(
347 const auto better = [&]() {
348 if (!best.has_value()) {
352 != best->coverage_basis_points) {
353 return candidate.coverage_basis_points
354 > best->coverage_basis_points;
356 if (candidate.claimed_works.size()
357 != best->claimed_works.size()) {
358 return candidate.claimed_works.size()
359 < best->claimed_works.size();
362 graph.agents[candidate.agent_index].id,
363 graph.agents[best->agent_index].id
367 best = std::move(candidate);
370 if (!best.has_value()) {
373 selected[best->agent_index] =
true;
374 for (
const auto work_index : best->claimed_works) {
375 claimed[work_index] =
true;
377 best->rank = result.size() + 1;
378 result.push_back(std::move(*best));
466 std::vector<ranked_candidate>& pool,
const graph_input& graph,
473 std::vector<feature_vector> features(pool.size());
474 for (std::size_t index = 0; index < pool.size(); ++index) {
475 collect_profile_features(
476 graph.agents[pool[index].agent_index].profile, {},
481 std::vector<std::size_t> seeds { 0 };
482 while (seeds.size() < std::min(group_count, pool.size())) {
483 std::optional<std::size_t> best;
484 double best_score = -1.0;
485 for (std::size_t candidate = 0; candidate < pool.size();
487 if (std::ranges::find(seeds, candidate) != seeds.end()) {
490 double maximum_similarity = 0.0;
491 for (
const auto seed : seeds) {
492 maximum_similarity = std::max(
494 weighted_jaccard(features[candidate], features[seed])
497 const double quality = 1.0
498 -
static_cast<
double>(candidate)
499 /
static_cast<
double>(
500 std::max<std::size_t>(1, pool.size() - 1)
503 = 0.75 * (1.0 - maximum_similarity) + 0.25 * quality;
504 if (score > best_score
505 || (!(score < best_score)
506 && (!best.has_value()
508 graph.agents[pool[candidate].agent_index].id,
509 graph.agents[pool[*best].agent_index].id
515 if (!best.has_value()) {
518 seeds.push_back(*best);
521 std::vector<std::vector<std::size_t>> groups(group_count);
522 std::vector<feature_vector> centers(group_count);
523 for (std::size_t index = 0; index < seeds.size(); ++index) {
524 centers[index] = features[seeds[index]];
526 const auto capacity_base = pool.size() / group_count;
527 const auto capacity_extra = pool.size() % group_count;
528 std::vector<std::size_t> capacities(group_count, capacity_base);
529 for (std::size_t index = 0; index < capacity_extra; ++index) {
533 for (
int iteration = 0; iteration < 4; ++iteration) {
534 for (
auto& group : groups) {
537 for (std::size_t candidate = 0; candidate < pool.size();
539 std::size_t chosen = 0;
540 double best_similarity = -1.0;
542 for (std::size_t group = 0; group < group_count; ++group) {
543 if (groups[group].size() >= capacities[group]) {
546 const double similarity
547 = weighted_jaccard(features[candidate], centers[group]);
548 if (!found || similarity > best_similarity
549 || (!(similarity < best_similarity)
550 && groups[group].size() < groups[chosen].size())) {
553 best_similarity = similarity;
556 groups[chosen].push_back(candidate);
557 pool[candidate].group = chosen + 1;
559 for (std::size_t group = 0; group < group_count; ++group) {
560 centers[group] = centroid(groups[group], features);
564 const auto target = std::min(configuration
.target_size, pool.size());
565 const auto target_base = target / group_count;
566 const auto target_extra = target % group_count;
567 std::vector<
bool> keep(pool.size(),
false);
568 for (std::size_t group = 0; group < group_count; ++group) {
569 std::vector<std::pair<
double, std::size_t>> scores;
570 for (
const auto candidate : groups[group]) {
571 const double quality = 1.0
572 -
static_cast<
double>(pool[candidate].rank - 1)
573 /
static_cast<
double>(
574 std::max<std::size_t>(1, pool.size())
576 const double affinity
577 = weighted_jaccard(features[candidate], centers[group]);
578 const double score = configuration.quality_weight * quality
579 + (1.0 - configuration.quality_weight) * affinity;
580 pool[candidate].grouping_score = score;
581 scores.emplace_back(score, candidate);
583 std::ranges::sort(scores, [&](
const auto& left,
const auto& right) {
584 if (left.first > right.first) {
585 return left.first > right.first;
587 if (left.first < right.first) {
590 if (pool[left.second].rank != pool[right.second].rank) {
591 return pool[left.second].rank < pool[right.second].rank;
594 graph.agents[pool[left.second].agent_index].id,
595 graph.agents[pool[right.second].agent_index].id
598 const auto quota = target_base + (group < target_extra ? 1U : 0U);
599 for (std::size_t index = 0; index < std::min(quota, scores.size());
601 keep[scores[index].second] =
true;
609 std::unordered_set<std::size_t> retained_works;
610 std::vector<std::size_t> retained_load(pool.size(), 0);
611 for (std::size_t index = 0; index < pool.size(); ++index) {
615 retained_load[index] = pool[index].claimed_works.size();
616 retained_works.insert(
617 pool[index].claimed_works.begin(),
618 pool[index].claimed_works.end()
621 for (std::size_t excluded = 0; excluded < pool.size(); ++excluded) {
622 if (keep[excluded]) {
625 for (
const auto work_index : pool[excluded].claimed_works) {
626 if (retained_works.contains(work_index)) {
629 std::optional<std::size_t> chosen;
630 for (std::size_t candidate = 0; candidate < pool.size();
633 || !std::ranges::binary_search(
634 graph.agents[pool[candidate].agent_index].works,
635 work_index, [&](
const auto left,
const auto right) {
637 graph.works[left].id, graph.works[right].id
643 const auto better = [&]() {
644 if (!chosen.has_value()) {
647 const bool same_group
648 = pool[candidate].group == pool[excluded].group;
649 const bool chosen_same_group
650 = pool[*chosen].group == pool[excluded].group;
651 if (same_group != chosen_same_group) {
654 if (retained_load[candidate]
655 != retained_load[*chosen]) {
656 return retained_load[candidate]
657 < retained_load[*chosen];
659 if (pool[candidate].rank != pool[*chosen].rank) {
660 return pool[candidate].rank < pool[*chosen].rank;
663 graph.agents[pool[candidate].agent_index].id,
664 graph.agents[pool[*chosen].agent_index].id
671 if (chosen.has_value()) {
672 pool[*chosen].claimed_works.push_back(work_index);
674 pool[*chosen].claimed_works,
675 [&](
const auto left,
const auto right) {
677 graph.works[left].id, graph.works[right].id
681 ++retained_load[*chosen];
682 retained_works.insert(work_index);
686 std::vector<ranked_candidate> selected;
687 selected.reserve(target);
688 for (std::size_t index = 0; index < pool.size(); ++index) {
690 selected.push_back(std::move(pool[index]));
693 std::ranges::sort(selected, [](
const auto& left,
const auto& right) {
694 return left.rank < right.rank;
696 pool = std::move(selected);