Arachne 1.0
Arachne - the perpetual stitcher of Wikidata entities.
Loading...
Searching...
No Matches
arachne::ariadne::anonymous_namespace{candidates.cpp} Namespace Reference

Classes

struct  work_record
struct  agent_record
struct  ranked_candidate
struct  graph_input

Typedefs

using feature_vector = std::map<std::string, double, std::less<>>

Functions

std::string required_string (const nlohmann::json &value, std::string_view field, std::string_view context)
void require_only_fields (const nlohmann::json &value, const std::set< std::string_view, std::less<> > &allowed, std::string_view context)
void require_stable_id (std::string_view value, std::string_view context)
std::uint64_t natural_identifier (std::string_view value)
bool id_less (std::string_view left, std::string_view right)
graph_input parse_graph (const nlohmann::json &document)
void validate_configuration (const candidate_configuration &configuration)
std::vector< ranked_candidaterank_pool (const graph_input &graph, const candidate_configuration &configuration)
void collect_profile_features (const nlohmann::json &value, std::string prefix, feature_vector &output)
double weighted_jaccard (const feature_vector &left, const feature_vector &right)
feature_vector centroid (const std::vector< std::size_t > &members, const std::vector< feature_vector > &features)
void group_and_select (std::vector< ranked_candidate > &pool, const graph_input &graph, const candidate_configuration &configuration)
std::string selection_explanation (const ranked_candidate &candidate)
void publish_immutable_file (const std::filesystem::path &destination, std::string_view bytes)

Typedef Documentation

◆ feature_vector

using arachne::ariadne::anonymous_namespace{candidates.cpp}::feature_vector = std::map<std::string, double, std::less<>>

Definition at line 383 of file candidates.cpp.

Function Documentation

◆ centroid()

feature_vector arachne::ariadne::anonymous_namespace{candidates.cpp}::centroid ( const std::vector< std::size_t > & members,
const std::vector< feature_vector > & features )

Definition at line 445 of file candidates.cpp.

448 {
449 feature_vector result;
450 if (members.empty()) {
451 return result;
452 }
453 for (const auto member : members) {
454 for (const auto& [token, weight] : features[member]) {
455 result[token] += weight;
456 }
457 }
458 for (auto& [token, weight] : result) {
459 static_cast<void>(token);
460 weight /= static_cast<double>(members.size());
461 }
462 return result;
463 }
std::map< std::string, double, std::less<> > feature_vector

◆ collect_profile_features()

void arachne::ariadne::anonymous_namespace{candidates.cpp}::collect_profile_features ( const nlohmann::json & value,
std::string prefix,
feature_vector & output )

Definition at line 385 of file candidates.cpp.

387 {
388 if (value.is_string()) {
389 const auto& text = value.get_ref<const std::string&>();
390 if (!text.empty()) {
391 output[prefix + ":" + text] = 1.0;
392 }
393 return;
394 }
395 if (value.is_number_integer()
396 && prefix.find("year") != std::string::npos) {
397 const auto year = value.get<std::int64_t>();
398 const auto bucket = static_cast<std::int64_t>(
399 std::floor(static_cast<double>(year) / 25.0) * 25.0
400 );
401 output["period:" + std::to_string(bucket)] = 5.0;
402 return;
403 }
404 if (value.is_array()) {
405 for (const auto& child : value) {
406 collect_profile_features(child, prefix, output);
407 }
408 return;
409 }
410 if (value.is_object()) {
411 for (auto iterator = value.begin(); iterator != value.end();
412 ++iterator) {
413 const std::string child_prefix = prefix.empty()
414 ? iterator.key()
415 : prefix + "." + iterator.key();
417 iterator.value(), child_prefix, output
418 );
419 }
420 }
421 }
void collect_profile_features(const nlohmann::json &value, std::string prefix, feature_vector &output)

◆ group_and_select()

void arachne::ariadne::anonymous_namespace{candidates.cpp}::group_and_select ( std::vector< ranked_candidate > & pool,
const graph_input & graph,
const candidate_configuration & configuration )

Definition at line 465 of file candidates.cpp.

468 {
469 if (pool.empty()) {
470 return;
471 }
472 const auto group_count = configuration.group_count;
473 std::vector<feature_vector> features(pool.size());
474 for (std::size_t index = 0; index < pool.size(); ++index) {
476 graph.agents[pool[index].agent_index].profile, {},
477 features[index]
478 );
479 }
480
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();
486 ++candidate) {
487 if (std::ranges::find(seeds, candidate) != seeds.end()) {
488 continue;
489 }
490 double maximum_similarity = 0.0;
491 for (const auto seed : seeds) {
492 maximum_similarity = std::max(
493 maximum_similarity,
494 weighted_jaccard(features[candidate], features[seed])
495 );
496 }
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)
501 );
502 const double score
503 = 0.75 * (1.0 - maximum_similarity) + 0.25 * quality;
504 if (score > best_score
505 || (!(score < best_score)
506 && (!best.has_value()
507 || id_less(
508 graph.agents[pool[candidate].agent_index].id,
509 graph.agents[pool[*best].agent_index].id
510 )))) {
511 best = candidate;
512 best_score = score;
513 }
514 }
515 if (!best.has_value()) {
516 break;
517 }
518 seeds.push_back(*best);
519 }
520
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]];
525 }
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) {
530 ++capacities[index];
531 }
532
533 for (int iteration = 0; iteration < 4; ++iteration) {
534 for (auto& group : groups) {
535 group.clear();
536 }
537 for (std::size_t candidate = 0; candidate < pool.size();
538 ++candidate) {
539 std::size_t chosen = 0;
540 double best_similarity = -1.0;
541 bool found = false;
542 for (std::size_t group = 0; group < group_count; ++group) {
543 if (groups[group].size() >= capacities[group]) {
544 continue;
545 }
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())) {
551 found = true;
552 chosen = group;
553 best_similarity = similarity;
554 }
555 }
556 groups[chosen].push_back(candidate);
557 pool[candidate].group = chosen + 1;
558 }
559 for (std::size_t group = 0; group < group_count; ++group) {
560 centers[group] = centroid(groups[group], features);
561 }
562 }
563
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())
575 );
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);
582 }
583 std::ranges::sort(scores, [&](const auto& left, const auto& right) {
584 if (left.first > right.first) {
585 return left.first > right.first;
586 }
587 if (left.first < right.first) {
588 return false;
589 }
590 if (pool[left.second].rank != pool[right.second].rank) {
591 return pool[left.second].rank < pool[right.second].rank;
592 }
593 return id_less(
594 graph.agents[pool[left.second].agent_index].id,
595 graph.agents[pool[right.second].agent_index].id
596 );
597 });
598 const auto quota = target_base + (group < target_extra ? 1U : 0U);
599 for (std::size_t index = 0; index < std::min(quota, scores.size());
600 ++index) {
601 keep[scores[index].second] = true;
602 }
603 }
604
605 // Preserve as much of the first-pass work coverage as possible. A work
606 // owned by an excluded pool member may move only to a retained agent
607 // that has the same source-graph edge. This does not invent
608 // relationships.
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) {
612 if (!keep[index]) {
613 continue;
614 }
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()
619 );
620 }
621 for (std::size_t excluded = 0; excluded < pool.size(); ++excluded) {
622 if (keep[excluded]) {
623 continue;
624 }
625 for (const auto work_index : pool[excluded].claimed_works) {
626 if (retained_works.contains(work_index)) {
627 continue;
628 }
629 std::optional<std::size_t> chosen;
630 for (std::size_t candidate = 0; candidate < pool.size();
631 ++candidate) {
632 if (!keep[candidate]
633 || !std::ranges::binary_search(
634 graph.agents[pool[candidate].agent_index].works,
635 work_index, [&](const auto left, const auto right) {
636 return id_less(
637 graph.works[left].id, graph.works[right].id
638 );
639 }
640 )) {
641 continue;
642 }
643 const auto better = [&]() {
644 if (!chosen.has_value()) {
645 return true;
646 }
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) {
652 return same_group;
653 }
654 if (retained_load[candidate]
655 != retained_load[*chosen]) {
656 return retained_load[candidate]
657 < retained_load[*chosen];
658 }
659 if (pool[candidate].rank != pool[*chosen].rank) {
660 return pool[candidate].rank < pool[*chosen].rank;
661 }
662 return id_less(
663 graph.agents[pool[candidate].agent_index].id,
664 graph.agents[pool[*chosen].agent_index].id
665 );
666 };
667 if (better()) {
668 chosen = candidate;
669 }
670 }
671 if (chosen.has_value()) {
672 pool[*chosen].claimed_works.push_back(work_index);
673 std::ranges::sort(
674 pool[*chosen].claimed_works,
675 [&](const auto left, const auto right) {
676 return id_less(
677 graph.works[left].id, graph.works[right].id
678 );
679 }
680 );
681 ++retained_load[*chosen];
682 retained_works.insert(work_index);
683 }
684 }
685 }
686 std::vector<ranked_candidate> selected;
687 selected.reserve(target);
688 for (std::size_t index = 0; index < pool.size(); ++index) {
689 if (keep[index]) {
690 selected.push_back(std::move(pool[index]));
691 }
692 }
693 std::ranges::sort(selected, [](const auto& left, const auto& right) {
694 return left.rank < right.rank;
695 });
696 pool = std::move(selected);
697 }
bool id_less(std::string_view left, std::string_view right)
feature_vector centroid(const std::vector< std::size_t > &members, const std::vector< feature_vector > &features)
double weighted_jaccard(const feature_vector &left, const feature_vector &right)

References arachne::ariadne::candidate_configuration::group_count, and arachne::ariadne::candidate_configuration::target_size.

◆ id_less()

bool arachne::ariadne::anonymous_namespace{candidates.cpp}::id_less ( std::string_view left,
std::string_view right )

Definition at line 129 of file candidates.cpp.

129 {
130 const auto left_number = natural_identifier(left);
131 const auto right_number = natural_identifier(right);
132 if (left_number != right_number) {
133 return left_number < right_number;
134 }
135 return left < right;
136 }
std::uint64_t natural_identifier(std::string_view value)

References natural_identifier().

Here is the call graph for this function:

◆ natural_identifier()

std::uint64_t arachne::ariadne::anonymous_namespace{candidates.cpp}::natural_identifier ( std::string_view value)

Definition at line 104 of file candidates.cpp.

104 {
105 std::size_t offset = 0;
106 while (offset < value.size()
107 && (value[offset] < '0' || value[offset] > '9')) {
108 ++offset;
109 }
110 if (offset == value.size()) {
111 return std::numeric_limits<std::uint64_t>::max();
112 }
113 std::uint64_t result = 0;
114 for (; offset < value.size(); ++offset) {
115 const char character = value[offset];
116 if (character < '0' || character > '9') {
117 return std::numeric_limits<std::uint64_t>::max();
118 }
119 const auto digit = static_cast<std::uint64_t>(character - '0');
120 if (result
121 > (std::numeric_limits<std::uint64_t>::max() - digit) / 10U) {
122 return std::numeric_limits<std::uint64_t>::max();
123 }
124 result = result * 10U + digit;
125 }
126 return result;
127 }

Referenced by id_less().

Here is the caller graph for this function:

◆ parse_graph()

graph_input arachne::ariadne::anonymous_namespace{candidates.cpp}::parse_graph ( const nlohmann::json & document)

Definition at line 138 of file candidates.cpp.

138 {
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"
145 );
146 }
148 document,
149 { "artifact_type", "format_version", "source_snapshot", "works",
150 "agents", "edges" },
151 "external_graph"
152 );
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"
157 );
158 }
159 const auto& source = document.at("source_snapshot");
161 source, { "snapshot_id", "storage_ref", "sha256" },
162 "external_graph.source_snapshot"
163 );
164 graph_input result;
166 source, "snapshot_id", "external_graph.source_snapshot"
167 );
168 result.source_storage_ref = required_string(
169 source, "storage_ref", "external_graph.source_snapshot"
170 );
171 result.source_sha256 = required_string(
172 source, "sha256", "external_graph.source_snapshot"
173 );
175 result.source_snapshot_id,
176 "external_graph.source_snapshot.snapshot_id"
177 );
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');
182 })) {
183 throw std::invalid_argument(
184 "source_sha256 must contain 64 hex characters"
185 );
186 }
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"
193 );
194 }
195
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");
200 }
201 work_record work;
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");
209 }
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);
213 }
214 result.works.emplace_back(std::move(work));
215 }
216
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");
221 }
222 agent_record agent;
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");
230 }
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);
234 }
235 result.agents.emplace_back(std::move(agent));
236 }
237
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");
242 }
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"
251 );
252 }
253 if (unique_edges.emplace(agent->second, work->second).second) {
254 result.agents[agent->second].works.push_back(work->second);
255 }
256 }
257 for (auto& agent : result.agents) {
258 std::ranges::sort(
259 agent.works, [&](const auto left, const auto right) {
260 return id_less(
261 result.works[left].id, result.works[right].id
262 );
263 }
264 );
265 }
266 return result;
267 }
void require_only_fields(const nlohmann::json &value, const std::set< std::string_view, std::less<> > &allowed, std::string_view context)
void require_stable_id(std::string_view value, std::string_view context)
std::string required_string(const nlohmann::json &value, std::string_view field, std::string_view context)

References require_stable_id(), and arachne::ariadne::anonymous_namespace{candidates.cpp}::graph_input::source_snapshot_id.

Here is the call graph for this function:

◆ publish_immutable_file()

void arachne::ariadne::anonymous_namespace{candidates.cpp}::publish_immutable_file ( const std::filesystem::path & destination,
std::string_view bytes )

Definition at line 707 of file candidates.cpp.

709 {
710 if (destination.empty() || destination.filename().empty()) {
711 throw std::invalid_argument(
712 "candidate plan destination must be a file"
713 );
714 }
715 if (!destination.parent_path().empty()) {
716 std::filesystem::create_directories(destination.parent_path());
717 }
718 if (std::filesystem::exists(destination)) {
719 if (!std::filesystem::is_regular_file(destination)
720 || std::filesystem::file_size(destination) != bytes.size()
721 || crypto::sha256_file(destination) != crypto::sha256(bytes)) {
722 throw std::runtime_error(
723 "candidate plan destination already contains different "
724 "bytes"
725 );
726 }
727 return;
728 }
729 auto staging = destination;
730 staging += ".part";
731 if (std::filesystem::exists(staging)) {
732 throw std::runtime_error(
733 "candidate plan staging file already exists"
734 );
735 }
736 try {
737 {
738 std::ofstream output(
739 staging, std::ios::binary | std::ios::trunc
740 );
741 if (!output) {
742 throw std::runtime_error(
743 "cannot create candidate plan artifact"
744 );
745 }
746 output.write(
747 bytes.data(), static_cast<std::streamsize>(bytes.size())
748 );
749 output.close();
750 if (!output) {
751 throw std::runtime_error(
752 "cannot finish candidate plan artifact"
753 );
754 }
755 }
756 std::filesystem::create_hard_link(staging, destination);
757 std::filesystem::remove(staging);
758 } catch (...) {
759 std::error_code ignored;
760 std::filesystem::remove(staging, ignored);
761 throw;
762 }
763 }
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

References arachne::crypto::sha256(), and arachne::crypto::sha256_file().

Referenced by arachne::ariadne::candidate_planner::write_plan().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ rank_pool()

std::vector< ranked_candidate > arachne::ariadne::anonymous_namespace{candidates.cpp}::rank_pool ( const graph_input & graph,
const candidate_configuration & configuration )

Definition at line 305 of file candidates.cpp.

307 {
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()));
312
313 while (result.size() < configuration.pool_size) {
314 std::optional<ranked_candidate> best;
315 for (std::size_t index = 0; index < graph.agents.size(); ++index) {
316 if (selected[index]) {
317 continue;
318 }
319 ranked_candidate candidate;
320 candidate.agent_index = index;
321 candidate.total_children = graph.agents[index].works.size();
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;
327 } else {
328 candidate.claimed_works.push_back(work_index);
329 }
330 }
331 const auto observed
332 = candidate.parsed_children + candidate.gray_children;
333 if (candidate.total_children == 0 || observed == 0
334 || candidate.claimed_works.empty()) {
335 continue;
336 }
337 const auto numerator = static_cast<std::int64_t>(
338 candidate.parsed_children * 10000U
339 + candidate.gray_children
340 * static_cast<std::size_t>(
341 10000 + configuration.gray_bonus_basis_points
342 )
343 );
344 candidate.coverage_basis_points = numerator
345 / static_cast<std::int64_t>(candidate.total_children);
346
347 const auto better = [&]() {
348 if (!best.has_value()) {
349 return true;
350 }
351 if (candidate.coverage_basis_points
352 != best->coverage_basis_points) {
353 return candidate.coverage_basis_points
354 > best->coverage_basis_points;
355 }
356 if (candidate.claimed_works.size()
357 != best->claimed_works.size()) {
358 return candidate.claimed_works.size()
359 < best->claimed_works.size();
360 }
361 return id_less(
362 graph.agents[candidate.agent_index].id,
363 graph.agents[best->agent_index].id
364 );
365 };
366 if (better()) {
367 best = std::move(candidate);
368 }
369 }
370 if (!best.has_value()) {
371 break;
372 }
373 selected[best->agent_index] = true;
374 for (const auto work_index : best->claimed_works) {
375 claimed[work_index] = true;
376 }
377 best->rank = result.size() + 1;
378 result.push_back(std::move(*best));
379 }
380 return result;
381 }

References arachne::ariadne::anonymous_namespace{candidates.cpp}::ranked_candidate::agent_index, arachne::ariadne::anonymous_namespace{candidates.cpp}::ranked_candidate::coverage_basis_points, arachne::ariadne::candidate_configuration::gray_bonus_basis_points, arachne::ariadne::anonymous_namespace{candidates.cpp}::ranked_candidate::gray_children, arachne::ariadne::anonymous_namespace{candidates.cpp}::ranked_candidate::parsed_children, arachne::ariadne::candidate_configuration::pool_size, and arachne::ariadne::anonymous_namespace{candidates.cpp}::ranked_candidate::total_children.

◆ require_only_fields()

void arachne::ariadne::anonymous_namespace{candidates.cpp}::require_only_fields ( const nlohmann::json & value,
const std::set< std::string_view, std::less<> > & allowed,
std::string_view context )

Definition at line 75 of file candidates.cpp.

79 {
80 for (auto iterator = value.begin(); iterator != value.end();
81 ++iterator) {
82 if (!allowed.contains(iterator.key())) {
83 throw std::invalid_argument(
84 std::string(context) + " contains unsupported field "
85 + iterator.key()
86 );
87 }
88 }
89 }

◆ require_stable_id()

void arachne::ariadne::anonymous_namespace{candidates.cpp}::require_stable_id ( std::string_view value,
std::string_view context )

Definition at line 91 of file candidates.cpp.

91 {
92 if (value.empty() || value.size() > 128U
93 || !std::ranges::all_of(value, [](const unsigned char character) {
94 return std::isalnum(character) != 0 || character == '.'
95 || character == '_' || character == ':'
96 || character == '-';
97 })) {
98 throw std::invalid_argument(
99 std::string(context) + " must be a stable identifier"
100 );
101 }
102 }

Referenced by parse_graph().

Here is the caller graph for this function:

◆ required_string()

std::string arachne::ariadne::anonymous_namespace{candidates.cpp}::required_string ( const nlohmann::json & value,
std::string_view field,
std::string_view context )

Definition at line 61 of file candidates.cpp.

64 {
65 if (!value.contains(field) || !value.at(field).is_string()
66 || value.at(field).get_ref<const std::string&>().empty()) {
67 throw std::invalid_argument(
68 std::string(context) + "." + std::string(field)
69 + " must be a non-empty string"
70 );
71 }
72 return value.at(field).get<std::string>();
73 }

◆ selection_explanation()

std::string arachne::ariadne::anonymous_namespace{candidates.cpp}::selection_explanation ( const ranked_candidate & candidate)

Definition at line 699 of file candidates.cpp.

699 {
700 return "Selected by deterministic coverage/grey-node ranking at pool "
701 "rank "
702 + std::to_string(candidate.rank) + " with coverage "
703 + std::to_string(candidate.coverage_basis_points)
704 + " basis points, then retained by balanced metadata grouping.";
705 }

References arachne::ariadne::anonymous_namespace{candidates.cpp}::ranked_candidate::coverage_basis_points, and arachne::ariadne::anonymous_namespace{candidates.cpp}::ranked_candidate::rank.

◆ validate_configuration()

void arachne::ariadne::anonymous_namespace{candidates.cpp}::validate_configuration ( const candidate_configuration & configuration)

Definition at line 269 of file candidates.cpp.

269 {
270 constexpr std::size_t maximum_candidates = 100000U;
271 constexpr std::size_t maximum_groups = 128U;
272 constexpr int maximum_gray_bonus_basis_points = 1000000;
273 if (configuration.pool_size == 0 || configuration.target_size == 0
274 || configuration.group_count == 0) {
275 throw std::invalid_argument(
276 "candidate pool, target, and group count must be positive"
277 );
278 }
279 if (configuration.target_size > configuration.pool_size) {
280 throw std::invalid_argument(
281 "candidate target cannot exceed pool size"
282 );
283 }
284 if (configuration.pool_size > maximum_candidates
285 || configuration.target_size > maximum_candidates
286 || configuration.group_count > maximum_groups) {
287 throw std::invalid_argument(
288 "candidate configuration exceeds safe bounds"
289 );
290 }
291 if (configuration.gray_bonus_basis_points < 0
292 || configuration.gray_bonus_basis_points
293 > maximum_gray_bonus_basis_points) {
294 throw std::invalid_argument("gray bonus exceeds safe bounds");
295 }
296 if (!std::isfinite(configuration.quality_weight)
297 || configuration.quality_weight < 0.0
298 || configuration.quality_weight > 1.0) {
299 throw std::invalid_argument(
300 "quality weight must be between zero and one"
301 );
302 }
303 }

References arachne::ariadne::candidate_configuration::gray_bonus_basis_points, arachne::ariadne::candidate_configuration::group_count, arachne::ariadne::candidate_configuration::pool_size, arachne::ariadne::candidate_configuration::quality_weight, and arachne::ariadne::candidate_configuration::target_size.

Referenced by arachne::ariadne::candidate_planner::build(), and arachne::ariadne::candidate_planner::configuration_values().

Here is the caller graph for this function:

◆ weighted_jaccard()

double arachne::ariadne::anonymous_namespace{candidates.cpp}::weighted_jaccard ( const feature_vector & left,
const feature_vector & right )

Definition at line 424 of file candidates.cpp.

424 {
425 if (left.empty() || right.empty()) {
426 return 0.0;
427 }
428 double intersection = 0.0;
429 double left_sum = 0.0;
430 double right_sum = 0.0;
431 for (const auto& [token, weight] : left) {
432 left_sum += weight;
433 if (const auto found = right.find(token); found != right.end()) {
434 intersection += std::min(weight, found->second);
435 }
436 }
437 for (const auto& [token, weight] : right) {
438 static_cast<void>(token);
439 right_sum += weight;
440 }
441 const double union_weight = left_sum + right_sum - intersection;
442 return union_weight > 0.0 ? intersection / union_weight : 0.0;
443 }