2026-02-08 20:57:58 -07:00
|
|
|
|
#pragma once
|
|
|
|
|
|
// Step 65: Memory strategy inference
|
|
|
|
|
|
//
|
|
|
|
|
|
// MemoryStrategyInference: analyses an AST and suggests appropriate
|
|
|
|
|
|
// memory annotations based on the module's target language and
|
|
|
|
|
|
// usage patterns. Suggestions are returned as data — they are
|
|
|
|
|
|
// never auto-applied to the AST.
|
|
|
|
|
|
|
|
|
|
|
|
#include <string>
|
|
|
|
|
|
#include <vector>
|
2026-02-09 19:28:10 -07:00
|
|
|
|
#include <unordered_map>
|
|
|
|
|
|
#include <algorithm>
|
2026-02-16 09:39:51 -07:00
|
|
|
|
#include <cctype>
|
2026-02-08 20:57:58 -07:00
|
|
|
|
#include "ast/ASTNode.h"
|
|
|
|
|
|
#include "ast/Module.h"
|
|
|
|
|
|
#include "ast/Function.h"
|
|
|
|
|
|
#include "ast/Variable.h"
|
2026-02-16 09:39:51 -07:00
|
|
|
|
#include "ast/Type.h"
|
2026-02-08 20:57:58 -07:00
|
|
|
|
#include "ast/Expression.h"
|
|
|
|
|
|
#include "ast/Annotation.h"
|
|
|
|
|
|
|
|
|
|
|
|
class MemoryStrategyInference {
|
|
|
|
|
|
public:
|
|
|
|
|
|
struct Suggestion {
|
|
|
|
|
|
std::string nodeId;
|
|
|
|
|
|
std::string annotationType; // e.g. "ReclaimAnnotation"
|
|
|
|
|
|
std::string strategy; // e.g. "Tracing"
|
|
|
|
|
|
std::string reason;
|
|
|
|
|
|
double confidence; // 0.0 – 1.0
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-09 19:28:10 -07:00
|
|
|
|
struct FeedbackStats {
|
|
|
|
|
|
int accepted = 0;
|
|
|
|
|
|
int rejected = 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-08 20:57:58 -07:00
|
|
|
|
// Analyse the AST and return suggestions. Does NOT modify the AST.
|
|
|
|
|
|
std::vector<Suggestion> inferAnnotations(const ASTNode* root) const {
|
|
|
|
|
|
std::vector<Suggestion> out;
|
|
|
|
|
|
if (!root) return out;
|
|
|
|
|
|
inferNode(root, out);
|
2026-02-09 19:28:10 -07:00
|
|
|
|
applyFeedback(out);
|
2026-02-08 20:57:58 -07:00
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 19:28:10 -07:00
|
|
|
|
void recordFeedback(const Suggestion& suggestion, bool accepted) const {
|
|
|
|
|
|
auto& stats = feedback_[feedbackKey(suggestion)];
|
|
|
|
|
|
if (accepted) {
|
|
|
|
|
|
stats.accepted += 1;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stats.rejected += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 20:57:58 -07:00
|
|
|
|
private:
|
2026-02-09 19:28:10 -07:00
|
|
|
|
inline static std::unordered_map<std::string, FeedbackStats> feedback_;
|
|
|
|
|
|
|
2026-02-08 20:57:58 -07:00
|
|
|
|
void inferNode(const ASTNode* node,
|
|
|
|
|
|
std::vector<Suggestion>& out) const {
|
|
|
|
|
|
if (!node) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (node->conceptType == "Module") {
|
|
|
|
|
|
inferModule(static_cast<const Module*>(node), out);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Recurse — but we already handle functions inside inferModule,
|
|
|
|
|
|
// so only recurse into non-Module children here for nested modules.
|
|
|
|
|
|
for (auto* child : node->allChildren()) {
|
|
|
|
|
|
if (child->conceptType == "Module") {
|
|
|
|
|
|
inferNode(child, out);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void inferModule(const Module* mod,
|
|
|
|
|
|
std::vector<Suggestion>& out) const {
|
|
|
|
|
|
const auto& lang = mod->targetLanguage;
|
|
|
|
|
|
|
|
|
|
|
|
// --- language-level defaults ---
|
|
|
|
|
|
|
|
|
|
|
|
if (lang == "python" || lang == "elisp" || lang == "ruby" ||
|
2026-02-13 18:41:31 +00:00
|
|
|
|
lang == "javascript" || lang == "java" || lang == "csharp" ||
|
2026-02-16 11:22:40 -07:00
|
|
|
|
lang == "kotlin" || lang == "common-lisp" || lang == "commonlisp" ||
|
2026-02-16 11:36:22 -07:00
|
|
|
|
lang == "lisp" || lang == "cl" || lang == "scheme" || lang == "scm") {
|
2026-02-08 20:57:58 -07:00
|
|
|
|
// GC languages → @Reclaim(Tracing) on the module
|
|
|
|
|
|
out.push_back({mod->id, "ReclaimAnnotation", "Tracing",
|
|
|
|
|
|
lang + " uses tracing garbage collection",
|
|
|
|
|
|
0.95});
|
2026-02-16 11:22:40 -07:00
|
|
|
|
if (lang == "common-lisp" || lang == "commonlisp" ||
|
2026-02-16 11:36:22 -07:00
|
|
|
|
lang == "lisp" || lang == "cl" || lang == "scheme" || lang == "scm") {
|
2026-02-16 11:22:40 -07:00
|
|
|
|
out.push_back({mod->id, "OwnerAnnotation", "Shared_GC",
|
2026-02-16 11:36:22 -07:00
|
|
|
|
"Lisp-family values are GC-managed shared objects",
|
2026-02-16 11:22:40 -07:00
|
|
|
|
0.92});
|
|
|
|
|
|
}
|
2026-02-08 20:57:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
else if (lang == "go") {
|
|
|
|
|
|
out.push_back({mod->id, "ReclaimAnnotation", "Escape",
|
|
|
|
|
|
"Go uses escape-analysis-based GC", 0.90});
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (lang == "cpp") {
|
|
|
|
|
|
inferCppModule(mod, out);
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (lang == "c") {
|
2026-02-16 09:39:51 -07:00
|
|
|
|
inferCModule(mod, out);
|
2026-02-08 20:57:58 -07:00
|
|
|
|
}
|
|
|
|
|
|
else if (lang == "rust") {
|
|
|
|
|
|
out.push_back({mod->id, "OwnerAnnotation", "Single",
|
|
|
|
|
|
"Rust uses single-owner lifetime tracking", 0.95});
|
|
|
|
|
|
}
|
2026-02-16 09:57:00 -07:00
|
|
|
|
else if (lang == "wat" || lang == "wasm") {
|
|
|
|
|
|
inferWatModule(mod, out);
|
|
|
|
|
|
}
|
2026-02-08 20:57:58 -07:00
|
|
|
|
else if (lang == "swift" || lang == "objc") {
|
|
|
|
|
|
out.push_back({mod->id, "OwnerAnnotation", "Shared_ARC",
|
|
|
|
|
|
lang + " uses automatic reference counting", 0.90});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- per-function analysis ---
|
|
|
|
|
|
for (auto* child : mod->getChildren("functions")) {
|
|
|
|
|
|
if (child->conceptType == "Function") {
|
|
|
|
|
|
inferFunction(static_cast<const Function*>(child), lang, out);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void inferCppModule(const Module* mod,
|
|
|
|
|
|
std::vector<Suggestion>& out) const {
|
|
|
|
|
|
// Default C++ suggestion: RAII is idiomatic
|
|
|
|
|
|
out.push_back({mod->id, "LifetimeAnnotation", "RAII",
|
|
|
|
|
|
"C++ idiom: RAII with scope-based cleanup", 0.70});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 09:39:51 -07:00
|
|
|
|
void inferCModule(const Module* mod,
|
|
|
|
|
|
std::vector<Suggestion>& out) const {
|
|
|
|
|
|
out.push_back({mod->id, "OwnerAnnotation", "Manual",
|
|
|
|
|
|
"C defaults to manual ownership", 0.95});
|
|
|
|
|
|
out.push_back({mod->id, "LifetimeAnnotation", "Scope",
|
|
|
|
|
|
"C locals default to lexical scope lifetime", 0.92});
|
|
|
|
|
|
out.push_back({mod->id, "ReclaimAnnotation", "Explicit",
|
|
|
|
|
|
"C uses explicit reclamation (free)", 0.95});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 09:57:00 -07:00
|
|
|
|
void inferWatModule(const Module* mod,
|
|
|
|
|
|
std::vector<Suggestion>& out) const {
|
|
|
|
|
|
out.push_back({mod->id, "OwnerAnnotation", "Manual",
|
|
|
|
|
|
"WAT linear memory is manually managed", 0.92});
|
|
|
|
|
|
out.push_back({mod->id, "ReclaimAnnotation", "Explicit",
|
|
|
|
|
|
"WAT has explicit memory lifetime operations", 0.88});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 20:57:58 -07:00
|
|
|
|
void inferFunction(const Function* fn, const std::string& lang,
|
|
|
|
|
|
std::vector<Suggestion>& out) const {
|
|
|
|
|
|
// Skip if function already has a memory annotation
|
|
|
|
|
|
if (hasMemoryAnnotation(fn)) return;
|
|
|
|
|
|
|
2026-02-16 09:39:51 -07:00
|
|
|
|
if (lang == "c") {
|
|
|
|
|
|
inferCFunction(fn, out);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-16 09:57:00 -07:00
|
|
|
|
if (lang == "wat" || lang == "wasm") {
|
|
|
|
|
|
inferWatFunction(fn, out);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-16 09:39:51 -07:00
|
|
|
|
|
2026-02-08 20:57:58 -07:00
|
|
|
|
// Check body for patterns
|
|
|
|
|
|
bool hasAlloc = false;
|
|
|
|
|
|
bool hasDealloc = false;
|
|
|
|
|
|
bool allConst = true;
|
|
|
|
|
|
|
|
|
|
|
|
for (auto* child : fn->getChildren("body")) {
|
|
|
|
|
|
if (child->conceptType == "Variable") {
|
|
|
|
|
|
// Any mutable variable means not all-const
|
|
|
|
|
|
allConst = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
scanForAllocDealloc(child, hasAlloc, hasDealloc);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (lang == "cpp") {
|
|
|
|
|
|
if (hasAlloc && !hasDealloc) {
|
|
|
|
|
|
out.push_back({fn->id, "LifetimeAnnotation", "RAII",
|
|
|
|
|
|
"Function allocates without explicit dealloc — "
|
|
|
|
|
|
"RAII recommended",
|
|
|
|
|
|
0.75});
|
|
|
|
|
|
} else if (hasAlloc && hasDealloc) {
|
|
|
|
|
|
out.push_back({fn->id, "DeallocateAnnotation", "Explicit",
|
|
|
|
|
|
"Function uses explicit alloc/dealloc pattern",
|
|
|
|
|
|
0.80});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Immutable function body (no variables) → static candidate
|
|
|
|
|
|
if (allConst && fn->getChildren("body").empty() == false) {
|
|
|
|
|
|
// Only suggest if not already covered by a language-level rule
|
|
|
|
|
|
if (lang == "cpp" || lang == "c") {
|
|
|
|
|
|
out.push_back({fn->id, "AllocateAnnotation", "Static",
|
|
|
|
|
|
"Function body contains no mutable state — "
|
|
|
|
|
|
"static allocation candidate",
|
|
|
|
|
|
0.50});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 09:39:51 -07:00
|
|
|
|
void inferCFunction(const Function* fn,
|
|
|
|
|
|
std::vector<Suggestion>& out) const {
|
|
|
|
|
|
bool hasAlloc = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (auto* paramNode : fn->getChildren("parameters")) {
|
|
|
|
|
|
if (paramNode->conceptType != "Parameter") continue;
|
|
|
|
|
|
auto* typeNode = paramNode->getChild("type");
|
|
|
|
|
|
if (isPointerType(typeNode)) {
|
|
|
|
|
|
out.push_back({paramNode->id, "OwnerAnnotation", "Borrowed",
|
|
|
|
|
|
"Pointer parameters in C are borrowed by default",
|
|
|
|
|
|
0.86});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (auto* stmt : fn->getChildren("body")) {
|
|
|
|
|
|
hasAlloc = hasAlloc || containsAllocCall(stmt);
|
|
|
|
|
|
inferCLocalLifetime(stmt, out);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto* retType = fn->getChild("returnType");
|
|
|
|
|
|
if (isPointerType(retType)) {
|
|
|
|
|
|
out.push_back({fn->id, "OwnerAnnotation", inferCReturnOwner(fn->name),
|
|
|
|
|
|
"C pointer returns are convention-based ownership transfers",
|
|
|
|
|
|
0.72});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hasAlloc) {
|
|
|
|
|
|
out.push_back({fn->id, "OwnerAnnotation", "Manual",
|
|
|
|
|
|
"malloc/calloc allocation indicates manual ownership",
|
|
|
|
|
|
0.94});
|
|
|
|
|
|
out.push_back({fn->id, "ReclaimAnnotation", "Explicit",
|
|
|
|
|
|
"malloc/calloc requires explicit free", 0.94});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 09:57:00 -07:00
|
|
|
|
void inferWatFunction(const Function* fn,
|
|
|
|
|
|
std::vector<Suggestion>& out) const {
|
|
|
|
|
|
bool hasMemoryOps = false;
|
|
|
|
|
|
for (auto* stmt : fn->getChildren("body")) {
|
|
|
|
|
|
hasMemoryOps = hasMemoryOps || containsWatMemoryOp(stmt);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasMemoryOps) {
|
|
|
|
|
|
out.push_back({fn->id, "OwnerAnnotation", "Manual",
|
|
|
|
|
|
"WAT memory/load/store operations require manual ownership",
|
|
|
|
|
|
0.90});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 20:57:58 -07:00
|
|
|
|
static bool hasMemoryAnnotation(const ASTNode* node) {
|
|
|
|
|
|
for (auto* anno : node->getChildren("annotations")) {
|
|
|
|
|
|
const auto& ct = anno->conceptType;
|
|
|
|
|
|
if (ct == "DeallocateAnnotation" || ct == "LifetimeAnnotation" ||
|
|
|
|
|
|
ct == "ReclaimAnnotation" || ct == "OwnerAnnotation" ||
|
|
|
|
|
|
ct == "AllocateAnnotation")
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static void scanForAllocDealloc(const ASTNode* node,
|
|
|
|
|
|
bool& hasAlloc, bool& hasDealloc) {
|
|
|
|
|
|
if (!node) return;
|
|
|
|
|
|
if (node->conceptType == "FunctionCall") {
|
|
|
|
|
|
auto* fc = static_cast<const FunctionCall*>(node);
|
|
|
|
|
|
if (fc->functionName == "malloc" || fc->functionName == "new" ||
|
|
|
|
|
|
fc->functionName == "calloc" || fc->functionName == "allocate")
|
|
|
|
|
|
hasAlloc = true;
|
|
|
|
|
|
if (fc->functionName == "free" || fc->functionName == "delete" ||
|
|
|
|
|
|
fc->functionName == "release" || fc->functionName == "deallocate")
|
|
|
|
|
|
hasDealloc = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (auto* child : node->allChildren()) {
|
|
|
|
|
|
scanForAllocDealloc(child, hasAlloc, hasDealloc);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-09 19:28:10 -07:00
|
|
|
|
|
2026-02-16 09:39:51 -07:00
|
|
|
|
static bool containsAllocCall(const ASTNode* node) {
|
|
|
|
|
|
if (!node) return false;
|
|
|
|
|
|
if (node->conceptType == "FunctionCall") {
|
|
|
|
|
|
auto* fc = static_cast<const FunctionCall*>(node);
|
|
|
|
|
|
if (fc->functionName == "malloc" || fc->functionName == "calloc") {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (auto* child : node->allChildren()) {
|
|
|
|
|
|
if (containsAllocCall(child)) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 09:57:00 -07:00
|
|
|
|
static bool containsWatMemoryOp(const ASTNode* node) {
|
|
|
|
|
|
if (!node) return false;
|
|
|
|
|
|
if (node->conceptType == "FunctionCall") {
|
|
|
|
|
|
auto* fc = static_cast<const FunctionCall*>(node);
|
|
|
|
|
|
std::string fn = lowerAscii(fc->functionName);
|
|
|
|
|
|
if (fn.find("memory.") != std::string::npos ||
|
|
|
|
|
|
fn.find(".load") != std::string::npos ||
|
|
|
|
|
|
fn.find(".store") != std::string::npos ||
|
|
|
|
|
|
fn == "memory.grow")
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (auto* child : node->allChildren()) {
|
|
|
|
|
|
if (containsWatMemoryOp(child)) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 09:39:51 -07:00
|
|
|
|
static void inferCLocalLifetime(const ASTNode* node,
|
|
|
|
|
|
std::vector<Suggestion>& out) {
|
|
|
|
|
|
if (!node) return;
|
|
|
|
|
|
if (node->conceptType == "Variable") {
|
|
|
|
|
|
auto* v = static_cast<const Variable*>(node);
|
|
|
|
|
|
auto* typeNode = v->getChild("type");
|
|
|
|
|
|
std::string typeKind = typeString(typeNode);
|
|
|
|
|
|
std::string lowered = lowerAscii(typeKind);
|
|
|
|
|
|
if (lowered.find("static") != std::string::npos) {
|
|
|
|
|
|
out.push_back({v->id, "LifetimeAnnotation", "Static",
|
|
|
|
|
|
"Static storage duration detected in C declaration",
|
|
|
|
|
|
0.90});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
out.push_back({v->id, "LifetimeAnnotation", "Scope",
|
|
|
|
|
|
"Local C variable uses scope-bound lifetime", 0.88});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (auto* child : node->allChildren()) {
|
|
|
|
|
|
inferCLocalLifetime(child, out);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static std::string inferCReturnOwner(const std::string& fnName) {
|
|
|
|
|
|
std::string lower = lowerAscii(fnName);
|
|
|
|
|
|
if (startsWith(lower, "get") || startsWith(lower, "borrow") ||
|
|
|
|
|
|
startsWith(lower, "peek") || startsWith(lower, "view")) {
|
|
|
|
|
|
return "Borrowed";
|
|
|
|
|
|
}
|
|
|
|
|
|
return "Transferred";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static bool isPointerType(const ASTNode* typeNode) {
|
|
|
|
|
|
return typeString(typeNode).find('*') != std::string::npos;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static std::string typeString(const ASTNode* typeNode) {
|
|
|
|
|
|
if (!typeNode) return "";
|
|
|
|
|
|
if (typeNode->conceptType == "PrimitiveType") {
|
|
|
|
|
|
return static_cast<const PrimitiveType*>(typeNode)->kind;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeNode->conceptType == "CustomType") {
|
|
|
|
|
|
return static_cast<const CustomType*>(typeNode)->typeName;
|
|
|
|
|
|
}
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static std::string lowerAscii(const std::string& input) {
|
|
|
|
|
|
std::string out = input;
|
|
|
|
|
|
std::transform(out.begin(), out.end(), out.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static bool startsWith(const std::string& value, const std::string& prefix) {
|
|
|
|
|
|
return value.rfind(prefix, 0) == 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 19:28:10 -07:00
|
|
|
|
static std::string feedbackKey(const Suggestion& suggestion) {
|
|
|
|
|
|
return suggestion.annotationType + ":" + suggestion.strategy;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void applyFeedback(std::vector<Suggestion>& out) const {
|
|
|
|
|
|
for (auto& s : out) {
|
|
|
|
|
|
auto it = feedback_.find(feedbackKey(s));
|
|
|
|
|
|
if (it == feedback_.end()) continue;
|
|
|
|
|
|
const auto& stats = it->second;
|
|
|
|
|
|
int total = stats.accepted + stats.rejected;
|
|
|
|
|
|
if (total <= 0) continue;
|
|
|
|
|
|
double bias = (double)(stats.accepted - stats.rejected) /
|
|
|
|
|
|
(double)total;
|
|
|
|
|
|
double factor = 1.0 + 0.25 * bias;
|
|
|
|
|
|
s.confidence = std::clamp(s.confidence * factor, 0.0, 1.0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 20:57:58 -07:00
|
|
|
|
};
|