diff --git a/Core/CLI/src/cli_Commands.h b/Core/CLI/src/cli_Commands.h index 9f026e8cb0..9a3e15f73b 100644 --- a/Core/CLI/src/cli_Commands.h +++ b/Core/CLI/src/cli_Commands.h @@ -1283,6 +1283,7 @@ namespace cli {'S', "stats", OPTARG_NONE}, {'t', "timers", OPTARG_NONE}, {'x', "export", OPTARG_NONE}, + {'R', "redundancy-check", OPTARG_NONE}, {0, nullptr, OPTARG_NONE} // null }; @@ -1400,6 +1401,15 @@ namespace cli return cli.DoSMem(option); + case 'R': + // case: redundancy-check takes no arguments + if (!opt.CheckNumNonOptArgs(0,0)) + { + return cli.SetError(opt.GetError()); + } + + return cli.DoSMem(option); + case 'r': { // case: remove requires one non-option argument, but can have a "force" argument diff --git a/Core/CLI/src/cli_help.cpp b/Core/CLI/src/cli_help.cpp index 8bb396680b..cce2512528 100644 --- a/Core/CLI/src/cli_help.cpp +++ b/Core/CLI/src/cli_help.cpp @@ -3044,6 +3044,7 @@ void initdocstrings() " smem --init Reinit smem store\n" " smem --query {(cue)* []} Query smem via given cue\n" " smem --remove { (id [^attr [value]])* } Remove smem structures\n" + " smem --redundancy-check Find dominated LTIs (experimental)\n" " ------------------------ Printing ---------------------\n" " print @ Print all smem contents\n" " print Print specific smem memory\n" diff --git a/Core/CLI/src/cli_smem.cpp b/Core/CLI/src/cli_smem.cpp index 75e3bacbad..a038ef7956 100644 --- a/Core/CLI/src/cli_smem.cpp +++ b/Core/CLI/src/cli_smem.cpp @@ -205,6 +205,16 @@ bool CommandLineInterface::DoSMem(const char pOp, const std::string* pArg1, cons thisAgent->SMem->calc_spread_trajectories(); thisAgent->SMem->timers->total->stop(); } + else if (pOp == 'R') + { + std::string result; + if (!thisAgent->SMem->CLI_redundancy_check(result)) + { + return SetError(result); + } + PrintCLIMessage(&result); + return true; + } else if (pOp == 'q') { std::string* err = new std::string; diff --git a/Core/SoarKernel/SoarKernel.cxx b/Core/SoarKernel/SoarKernel.cxx index 1adb21cf16..91ecb165b5 100644 --- a/Core/SoarKernel/SoarKernel.cxx +++ b/Core/SoarKernel/SoarKernel.cxx @@ -66,6 +66,7 @@ #include #include #include +#include #include #include #include diff --git a/Core/SoarKernel/src/episodic_memory/episodic_memory.cpp b/Core/SoarKernel/src/episodic_memory/episodic_memory.cpp index 7da7350610..e700c7f9d2 100644 --- a/Core/SoarKernel/src/episodic_memory/episodic_memory.cpp +++ b/Core/SoarKernel/src/episodic_memory/episodic_memory.cpp @@ -180,6 +180,26 @@ epmem_param_container::epmem_param_container(agent* new_agent): soar_module::par merge->add_mapping(merge_none, "none"); merge->add_mapping(merge_add, "add"); add(merge); + + //////////////////// + // Consolidation + //////////////////// + + // consolidate on/off + consolidate = new soar_module::boolean_param("consolidate", off, new soar_module::f_predicate()); + add(consolidate); + + // consolidate-interval + consolidate_interval = new soar_module::integer_param("consolidate-interval", 100, new soar_module::gt_predicate(0, true), new soar_module::f_predicate()); + add(consolidate_interval); + + // consolidate-threshold + consolidate_threshold = new soar_module::integer_param("consolidate-threshold", 10, new soar_module::gt_predicate(0, true), new soar_module::f_predicate()); + add(consolidate_threshold); + + // consolidate-evict-age: min episode age before eviction eligible (0 = no eviction) + consolidate_evict_age = new soar_module::integer_param("consolidate-evict-age", 0, new soar_module::gt_predicate(0, true), new soar_module::f_predicate()); + add(consolidate_evict_age); } // @@ -599,6 +619,10 @@ epmem_stat_container::epmem_stat_container(agent* new_agent): soar_module::stat_ next_id = new epmem_node_id_stat("next-id", 0, new epmem_db_predicate(thisAgent)); add(next_id); + // last-consolidation + last_consolidation = new epmem_time_id_stat("last-consolidation", 0, new soar_module::f_predicate()); + add(last_consolidation); + // rit-offset-1 rit_offset_1 = new soar_module::integer_stat("rit-offset-1", 0, new epmem_db_predicate(thisAgent)); add(rit_offset_1); @@ -966,6 +990,7 @@ void epmem_graph_statement_container::create_graph_tables() add_structure("CREATE TABLE IF NOT EXISTS epmem_wmes_constant (wc_id INTEGER PRIMARY KEY AUTOINCREMENT,parent_n_id INTEGER,attribute_s_id INTEGER, value_s_id INTEGER)"); add_structure("CREATE TABLE IF NOT EXISTS epmem_wmes_identifier (wi_id INTEGER PRIMARY KEY AUTOINCREMENT,parent_n_id INTEGER,attribute_s_id INTEGER,child_n_id INTEGER, last_episode_id INTEGER)"); add_structure("CREATE TABLE IF NOT EXISTS epmem_ascii (ascii_num INTEGER PRIMARY KEY, ascii_chr TEXT)"); + add_structure("CREATE TABLE IF NOT EXISTS epmem_consolidated (wc_id INTEGER PRIMARY KEY)"); } void epmem_graph_statement_container::create_graph_indices() @@ -1014,6 +1039,7 @@ void epmem_graph_statement_container::drop_graph_tables() add_structure("DROP TABLE IF EXISTS epmem_wmes_identifier_range"); add_structure("DROP TABLE IF EXISTS epmem_wmes_constant"); add_structure("DROP TABLE IF EXISTS epmem_wmes_identifier"); + add_structure("DROP TABLE IF EXISTS epmem_consolidated"); } epmem_graph_statement_container::epmem_graph_statement_container(agent* new_agent): soar_module::sqlite_statement_container(new_agent->EpMem->epmem_db) @@ -1153,6 +1179,43 @@ epmem_graph_statement_container::epmem_graph_statement_container(agent* new_agen update_epmem_wmes_identifier_last_episode_id = new soar_module::sqlite_statement(new_db, "UPDATE epmem_wmes_identifier SET last_episode_id=? WHERE wi_id=?"); add(update_epmem_wmes_identifier_last_episode_id); + // consolidation query: find constant WMEs present for >= threshold episodes, excluding already-consolidated + consolidate_find_stable = new soar_module::sqlite_statement(new_db, + "SELECT wc.wc_id, wc.parent_n_id, wc.attribute_s_id, wc.value_s_id " + "FROM epmem_wmes_constant wc " + "JOIN epmem_wmes_constant_now cn ON cn.wc_id = wc.wc_id " + "LEFT JOIN epmem_consolidated ec ON ec.wc_id = wc.wc_id " + "WHERE cn.start_episode_id <= (? - ?) " + " AND ec.wc_id IS NULL " + "ORDER BY wc.parent_n_id"); + add(consolidate_find_stable); + + // consolidation: mark wc_id as consolidated + consolidate_mark = new soar_module::sqlite_statement(new_db, + "INSERT OR IGNORE INTO epmem_consolidated (wc_id) VALUES (?)"); + add(consolidate_mark); + + // eviction: delete old episode rows and their point entries + consolidate_evict_episode = new soar_module::sqlite_statement(new_db, + "DELETE FROM epmem_episodes WHERE episode_id < ?"); + add(consolidate_evict_episode); + + consolidate_evict_constant_point = new soar_module::sqlite_statement(new_db, + "DELETE FROM epmem_wmes_constant_point WHERE episode_id < ?"); + add(consolidate_evict_constant_point); + + consolidate_evict_identifier_point = new soar_module::sqlite_statement(new_db, + "DELETE FROM epmem_wmes_identifier_point WHERE episode_id < ?"); + add(consolidate_evict_identifier_point); + + consolidate_evict_constant_range = new soar_module::sqlite_statement(new_db, + "DELETE FROM epmem_wmes_constant_range WHERE end_episode_id < ?"); + add(consolidate_evict_constant_range); + + consolidate_evict_identifier_range = new soar_module::sqlite_statement(new_db, + "DELETE FROM epmem_wmes_identifier_range WHERE end_episode_id < ?"); + add(consolidate_evict_identifier_range); + // init statement pools { int j, k, m; @@ -5913,6 +5976,185 @@ void epmem_respond_to_cmd(agent* thisAgent) * Notes : The kernel calls this function to implement Soar-EpMem: * consider new storage and respond to any commands **************************************************************************/ +/*************************************************************************** + * Function : epmem_consolidate + * Author : June Kim + * Notes : Scans episodic memory for stable WME structures and + * writes them to semantic memory. Implements the + * compose+test framework (Casteigts et al., 2019) for + * episodic-to-semantic consolidation. + * + * Compose: union of WMEs active in the current window + * Test: continuous presence >= consolidate-threshold episodes + * Write: create new smem LTI with qualifying augmentations + * + * Runs periodically based on consolidate-interval parameter. + * Off by default (consolidate = off). + **************************************************************************/ +void epmem_consolidate(agent* thisAgent) +{ + // Check if consolidation is enabled + if (thisAgent->EpMem->epmem_params->consolidate->get_value() == off) + { + return; + } + + // Check if epmem DB is connected + if (thisAgent->EpMem->epmem_db->get_status() != soar_module::connected) + { + return; + } + + // Check if smem is enabled (CLI_add will handle connection) + if (!thisAgent->SMem->enabled()) + { + return; + } + + epmem_time_id current_episode = thisAgent->EpMem->epmem_stats->time->get_value(); + epmem_time_id last_consol = thisAgent->EpMem->epmem_stats->last_consolidation->get_value(); + int64_t interval = thisAgent->EpMem->epmem_params->consolidate_interval->get_value(); + int64_t threshold = thisAgent->EpMem->epmem_params->consolidate_threshold->get_value(); + + // Check if enough episodes have passed since last consolidation + if ((current_episode - last_consol) < static_cast(interval)) + { + return; + } + + // Run the compose+test query: find constant WMEs present for >= threshold episodes + // Query now excludes already-consolidated wc_ids via LEFT JOIN + soar_module::sqlite_statement* find_stable = thisAgent->EpMem->epmem_stmts_graph->consolidate_find_stable; + find_stable->bind_int(1, current_episode); + find_stable->bind_int(2, threshold); + + // Collect results grouped by parent, filtering empty symbols before building string + struct consolidation_entry { + epmem_node_id wc_id; + std::string attr; + std::string value; + }; + + std::map> parent_groups; + + while (find_stable->execute() == soar_module::row) + { + epmem_node_id wc_id = find_stable->column_int(0); + epmem_node_id parent_n_id = find_stable->column_int(1); + epmem_hash_id attr_s_id = find_stable->column_int(2); + epmem_hash_id value_s_id = find_stable->column_int(3); + + // Reverse-hash the attribute and value to get printable strings + std::string attr_str, value_str; + epmem_reverse_hash_print(thisAgent, attr_s_id, attr_str); + epmem_reverse_hash_print(thisAgent, value_s_id, value_str); + + if (attr_str.empty() || value_str.empty()) continue; + + // Skip symbols containing pipe characters — they can't be safely quoted + if (attr_str.find('|') != std::string::npos || value_str.find('|') != std::string::npos) + { + continue; + } + + // Pipe-quote strings that contain special characters + auto needs_quoting = [](const std::string& s) { + for (char c : s) { + if (c == ' ' || c == '(' || c == ')' || c == '^' || c == '{' || c == '}') + return true; + } + return false; + }; + + if (needs_quoting(attr_str)) attr_str = "|" + attr_str + "|"; + if (needs_quoting(value_str)) value_str = "|" + value_str + "|"; + + consolidation_entry e; + e.wc_id = wc_id; + e.attr = attr_str; + e.value = value_str; + parent_groups[parent_n_id].push_back(e); + } + find_stable->reinitialize(); + + // Build smem add string and collect wc_ids + std::string smem_add_str; + std::vector consolidated_wc_ids; + int lti_count = 0; + + for (auto& kv : parent_groups) + { + if (kv.second.empty()) continue; + lti_count++; + smem_add_str += "("; + for (auto& e : kv.second) + { + smem_add_str += " ^" + e.attr + " " + e.value; + consolidated_wc_ids.push_back(e.wc_id); + } + smem_add_str += ")\n"; + } + + // Write to smem if we found anything + if (lti_count > 0) + { + std::string* err_msg = new std::string(""); + bool success = thisAgent->SMem->CLI_add(smem_add_str.c_str(), &err_msg); + delete err_msg; + + if (success) + { + // Record consolidated wc_ids to prevent duplicates on next run + for (epmem_node_id wc_id : consolidated_wc_ids) + { + thisAgent->EpMem->epmem_stmts_graph->consolidate_mark->bind_int(1, wc_id); + thisAgent->EpMem->epmem_stmts_graph->consolidate_mark->execute(soar_module::op_reinit); + } + } + } + + // Eviction: remove old episodes if evict-age is set + int64_t evict_age = thisAgent->EpMem->epmem_params->consolidate_evict_age->get_value(); + if (evict_age > 0 && current_episode > static_cast(evict_age)) + { + epmem_time_id evict_before = current_episode - evict_age; + + // Wrap eviction in a transaction if lazy_commit is off + // (when lazy_commit is on, we're already inside a transaction) + bool needs_txn = (thisAgent->EpMem->epmem_params->lazy_commit->get_value() == off); + if (needs_txn) + { + thisAgent->EpMem->epmem_stmts_common->begin->execute(soar_module::op_reinit); + } + + // Delete range entries whose intervals end entirely before the cutoff + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_constant_range->bind_int(1, evict_before); + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_constant_range->execute(soar_module::op_reinit); + + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_identifier_range->bind_int(1, evict_before); + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_identifier_range->execute(soar_module::op_reinit); + + // Delete point entries for old episodes + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_constant_point->bind_int(1, evict_before); + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_constant_point->execute(soar_module::op_reinit); + + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_identifier_point->bind_int(1, evict_before); + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_identifier_point->execute(soar_module::op_reinit); + + // Delete old episode rows + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_episode->bind_int(1, evict_before); + thisAgent->EpMem->epmem_stmts_graph->consolidate_evict_episode->execute(soar_module::op_reinit); + + if (needs_txn) + { + thisAgent->EpMem->epmem_stmts_common->commit->execute(soar_module::op_reinit); + } + } + + // Update last consolidation stat + thisAgent->EpMem->epmem_stats->last_consolidation->set_value(current_episode); +} + void epmem_go(agent* thisAgent, bool allow_store) { @@ -5924,6 +6166,8 @@ void epmem_go(agent* thisAgent, bool allow_store) } epmem_respond_to_cmd(thisAgent); + // Periodic consolidation: episodic -> semantic + epmem_consolidate(thisAgent); thisAgent->EpMem->epmem_timers->total->stop(); diff --git a/Core/SoarKernel/src/episodic_memory/episodic_memory.h b/Core/SoarKernel/src/episodic_memory/episodic_memory.h index 6630a5bda7..e5f9e42be4 100644 --- a/Core/SoarKernel/src/episodic_memory/episodic_memory.h +++ b/Core/SoarKernel/src/episodic_memory/episodic_memory.h @@ -81,6 +81,12 @@ class epmem_param_container: public soar_module::param_container soar_module::constant_param* gm_ordering; soar_module::constant_param* merge; + // consolidation + soar_module::boolean_param* consolidate; + soar_module::integer_param* consolidate_interval; + soar_module::integer_param* consolidate_threshold; + soar_module::integer_param* consolidate_evict_age; + epmem_param_container(agent* new_agent); }; @@ -135,6 +141,8 @@ class epmem_stat_container: public soar_module::stat_container epmem_node_id_stat* next_id; + epmem_time_id_stat* last_consolidation; + soar_module::integer_stat* rit_offset_1; soar_module::integer_stat* rit_left_root_1; soar_module::integer_stat* rit_right_root_1; @@ -329,6 +337,17 @@ class epmem_graph_statement_container: public soar_module::sqlite_statement_cont soar_module::sqlite_statement* update_epmem_wmes_identifier_last_episode_id; + // consolidation + soar_module::sqlite_statement* consolidate_find_stable; + soar_module::sqlite_statement* consolidate_mark; + + // eviction + soar_module::sqlite_statement* consolidate_evict_episode; + soar_module::sqlite_statement* consolidate_evict_constant_point; + soar_module::sqlite_statement* consolidate_evict_identifier_point; + soar_module::sqlite_statement* consolidate_evict_constant_range; + soar_module::sqlite_statement* consolidate_evict_identifier_range; + // soar_module::sqlite_statement_pool* pool_find_edge_queries[2][2]; @@ -471,6 +490,7 @@ extern void epmem_clear_transient_structures(agent* thisAgent); // perform epmem actions extern void epmem_go(agent* thisAgent, bool allow_store = true); +extern void epmem_consolidate(agent* thisAgent); extern bool epmem_backup_db(agent* thisAgent, const char* file_name, std::string* err); extern void epmem_init_db(agent* thisAgent, bool readonly = false); // visualization diff --git a/Core/SoarKernel/src/semantic_memory/semantic_memory.h b/Core/SoarKernel/src/semantic_memory/semantic_memory.h index c77dd1361c..e799e4b9e6 100644 --- a/Core/SoarKernel/src/semantic_memory/semantic_memory.h +++ b/Core/SoarKernel/src/semantic_memory/semantic_memory.h @@ -69,6 +69,7 @@ class SMem_Manager bool CLI_add(const char* str_to_LTMs, std::string** err_msg); bool CLI_query(const char* ltms, std::string** err_msg, std::string** result_message, uint64_t number_to_retrieve); bool CLI_remove(const char* ltms, std::string** err_msg, std::string** result_message, bool force = false); + bool CLI_redundancy_check(std::string& result); void calc_spread_trajectories(); void invalidate_trajectories(uint64_t lti_parent_id, std::map* delta_children); diff --git a/Core/SoarKernel/src/semantic_memory/smem_inclusion.cpp b/Core/SoarKernel/src/semantic_memory/smem_inclusion.cpp new file mode 100644 index 0000000000..70c1bdeccb --- /dev/null +++ b/Core/SoarKernel/src/semantic_memory/smem_inclusion.cpp @@ -0,0 +1,421 @@ +/* + * smem_inclusion.cpp + * + * Structural inclusion check for semantic memory (Kilpeläinen-Mannila 1995). + * Experimental -- detection only, no eviction. + * + * An LTI A "includes" LTI B if B's augmentation graph embeds injectively + * into A's: every slot (attribute -> values) in B has a matching slot in A + * with a superset of values. For child LTI values, recurse with + * backtracking to ensure correct injective matching. Constants match exactly. + * + * SMem graphs may contain cycles and shared substructure. Cycle handling + * uses a separate recursion stack (active_pairs) from the memoization + * table (memo) to avoid conflating "currently exploring" with "proven". + */ + +#include "semantic_memory.h" +#include "smem_db.h" +#include "smem_settings.h" + +#include "agent.h" +#include "output_manager.h" + +#include +#include +#include +#include +#include + +/* ---------------------------------------------------------------- + * Lightweight representation of an LTI's direct augmentations, + * keyed by raw hash IDs so we avoid Symbol allocation/deallocation. + * ---------------------------------------------------------------- */ + +struct smem_aug_const +{ + int64_t value_type; + int64_t value_hash; + + bool operator<(const smem_aug_const& o) const + { + if (value_type != o.value_type) return value_type < o.value_type; + return value_hash < o.value_hash; + } + bool operator==(const smem_aug_const& o) const + { + return value_type == o.value_type && value_hash == o.value_hash; + } +}; + +typedef std::pair attr_key; // (attr_type, attr_hash) + +struct smem_lti_augmentations +{ + uint64_t lti_id; + + // For each attribute, the set of constant values + std::map> const_values; + + // For each attribute, the multiset of child LTI ids (stored as vector for injective matching) + std::map> lti_values; +}; + +/* ---------------------------------------------------------------- + * Load an LTI's direct augmentations from the database. + * Uses web_expand: columns are attr_type(0), attr_hash(1), + * value_type(2), value_hash(3), value_lti(4). + * ---------------------------------------------------------------- */ +static smem_lti_augmentations load_lti_augs(SMem_Manager* smem, soar_module::sqlite_statement* expand_q, uint64_t lti_id) +{ + smem_lti_augmentations result; + result.lti_id = lti_id; + + expand_q->bind_int(1, lti_id); + while (expand_q->execute() == soar_module::row) + { + int64_t attr_type = expand_q->column_int(0); + int64_t attr_hash = expand_q->column_int(1); + attr_key ak = std::make_pair(attr_type, attr_hash); + + int64_t value_lti = expand_q->column_int(4); + if (value_lti != SMEM_AUGMENTATIONS_NULL) + { + result.lti_values[ak].push_back(static_cast(value_lti)); + } + else + { + smem_aug_const c; + c.value_type = expand_q->column_int(2); + c.value_hash = expand_q->column_int(3); + result.const_values[ak].insert(c); + } + } + expand_q->reinitialize(); + + return result; +} + +/* ---------------------------------------------------------------- + * Check whether LTI A includes LTI B (i.e. B is dominated by A). + * + * For every attribute in B: + * - A must have the same attribute + * - Every constant value under that attribute in B must appear in A + * - Every child LTI under that attribute in B must be injectively + * matched to a child LTI in A that recursively includes it + * + * Uses separate structures for cycle detection vs. memoization: + * active_pairs: recursion stack — pair is currently being explored + * memo: proven results — pair has been fully evaluated + * + * Cycle handling is coinductive: revisiting an active pair returns true + * (optimistic assumption). If the assumption is wrong, the non-cyclic + * parts of the proof will fail. This correctly handles self-referential + * structures like @1 ^next @1 vs @2 ^next @2. + * + * Global injectivity is enforced via b_to_a: a map from B node IDs to + * their assigned A node IDs, threaded through all recursion. Two + * distinct B nodes cannot map to the same A node even if reached + * through different attributes. + * + * Child matching uses backtracking (not greedy) to ensure correct + * injective assignment when first-fit would block later matches. + * ---------------------------------------------------------------- */ + +typedef std::pair lti_pair; + +static bool smem_lti_includes_impl( + SMem_Manager* smem, + soar_module::sqlite_statement* expand_q, + uint64_t lti_a, + uint64_t lti_b, + std::map& aug_cache, + std::set& active_pairs, + std::map& b_to_a); + +/* Backtracking injective matcher for child LTIs under one attribute. + * Tries to assign each b_lti to a distinct a_lti that includes it. + * Returns true if a complete injective matching exists. + * Enforces global injectivity via b_to_a map. + * Snapshots b_to_a before each speculative branch and restores on + * failure to prevent leaked descendant bindings. */ +static bool match_children_backtrack( + SMem_Manager* smem, + soar_module::sqlite_statement* expand_q, + const std::vector& a_ltis, + const std::vector& b_ltis, + size_t b_idx, + std::vector& a_used, + std::map& aug_cache, + std::set& active_pairs, + std::map& b_to_a) +{ + if (b_idx == b_ltis.size()) return true; // all B children matched + + uint64_t b_child = b_ltis[b_idx]; + + // If this B node was already assigned globally, only try that A node + auto prior = b_to_a.find(b_child); + if (prior != b_to_a.end()) + { + uint64_t required_a = prior->second; + for (size_t ai = 0; ai < a_ltis.size(); ai++) + { + if (a_used[ai] || a_ltis[ai] != required_a) continue; + a_used[ai] = true; + if (match_children_backtrack(smem, expand_q, a_ltis, b_ltis, b_idx + 1, a_used, aug_cache, active_pairs, b_to_a)) + { + return true; + } + a_used[ai] = false; + } + return false; + } + + for (size_t ai = 0; ai < a_ltis.size(); ai++) + { + if (a_used[ai]) continue; + + // Check global injectivity: is this A node already claimed by a different B node? + bool a_claimed = false; + for (auto& ba : b_to_a) + { + if (ba.second == a_ltis[ai] && ba.first != b_child) + { + a_claimed = true; + break; + } + } + if (a_claimed) continue; + + // Snapshot b_to_a before speculative branch + std::map b_to_a_snapshot = b_to_a; + + // Pre-bind before recursion so descendant checks see the intended assignment + b_to_a[b_child] = a_ltis[ai]; + + if (smem_lti_includes_impl(smem, expand_q, a_ltis[ai], b_child, aug_cache, active_pairs, b_to_a)) + { + a_used[ai] = true; + if (match_children_backtrack(smem, expand_q, a_ltis, b_ltis, b_idx + 1, a_used, aug_cache, active_pairs, b_to_a)) + { + return true; + } + a_used[ai] = false; + } + + // Restore full b_to_a state on failure (undoes all descendant bindings) + b_to_a = b_to_a_snapshot; + } + return false; +} + +static bool smem_lti_includes_impl( + SMem_Manager* smem, + soar_module::sqlite_statement* expand_q, + uint64_t lti_a, + uint64_t lti_b, + std::map& aug_cache, + std::set& active_pairs, + std::map& b_to_a) +{ + if (lti_a == lti_b) return true; + + lti_pair pair_key = std::make_pair(lti_a, lti_b); + + // Coinductive cycle handling: if we're currently exploring this pair, + // optimistically assume inclusion holds. If the assumption is wrong, + // the non-cyclic parts of the proof will fail. + if (active_pairs.count(pair_key)) return true; + active_pairs.insert(pair_key); + + // Load augmentations (with caching) + if (aug_cache.find(lti_a) == aug_cache.end()) + { + aug_cache[lti_a] = load_lti_augs(smem, expand_q, lti_a); + } + if (aug_cache.find(lti_b) == aug_cache.end()) + { + aug_cache[lti_b] = load_lti_augs(smem, expand_q, lti_b); + } + + const smem_lti_augmentations& augs_a = aug_cache[lti_a]; + const smem_lti_augmentations& augs_b = aug_cache[lti_b]; + + bool result = true; + + // Check constant values: for every attribute in B, A must have a superset + for (auto& b_entry : augs_b.const_values) + { + const attr_key& ak = b_entry.first; + const std::set& b_consts = b_entry.second; + + auto a_it = augs_a.const_values.find(ak); + if (a_it == augs_a.const_values.end()) + { + result = false; + break; + } + const std::set& a_consts = a_it->second; + + for (auto& bc : b_consts) + { + if (a_consts.find(bc) == a_consts.end()) + { + result = false; + break; + } + } + if (!result) break; + } + + // Check LTI children: injective matching with backtracking + if (result) + { + for (auto& b_entry : augs_b.lti_values) + { + const attr_key& ak = b_entry.first; + const std::vector& b_ltis = b_entry.second; + + auto a_it = augs_a.lti_values.find(ak); + if (a_it == augs_a.lti_values.end()) + { + result = false; + break; + } + const std::vector& a_ltis = a_it->second; + + if (a_ltis.size() < b_ltis.size()) + { + result = false; + break; + } + + std::vector a_used(a_ltis.size(), false); + if (!match_children_backtrack(smem, expand_q, a_ltis, b_ltis, 0, a_used, aug_cache, active_pairs, b_to_a)) + { + result = false; + break; + } + } + } + + active_pairs.erase(pair_key); + // No memoization: results depend on b_to_a context, which changes + // during backtracking. Smem entries are shallow, so the perf cost + // of re-evaluation is negligible. + return result; +} + +/* ---------------------------------------------------------------- + * Public entry point: check if LTI A includes LTI B. + * ---------------------------------------------------------------- */ +static bool smem_lti_includes(SMem_Manager* smem, soar_module::sqlite_statement* expand_q, uint64_t lti_a, uint64_t lti_b) +{ + std::map aug_cache; + std::set active_pairs; + std::map b_to_a; + // Pin root: B's root must map to A's root (rooted inclusion) + b_to_a[lti_b] = lti_a; + return smem_lti_includes_impl(smem, expand_q, lti_a, lti_b, aug_cache, active_pairs, b_to_a); +} + +/* ---------------------------------------------------------------- + * CLI_redundancy_check: scan all LTI pairs and report domination. + * + * Maintains a running non-dominated set. For each new LTI B, + * checks against existing dominators. Uses transitivity to skip + * already-dominated entries. + * ---------------------------------------------------------------- */ +bool SMem_Manager::CLI_redundancy_check(std::string& result) +{ + attach(); + if (!connected()) + { + result.append("Semantic memory database not connected."); + return false; + } + + // Collect all LTI IDs + std::vector all_ltis; + soar_module::sqlite_statement* q = SQL->lti_all; + while (q->execute() == soar_module::row) + { + all_ltis.push_back(static_cast(q->column_int(0))); + } + q->reinitialize(); + + if (all_ltis.empty()) + { + result.append("No LTIs in semantic memory.\n"); + return true; + } + + std::ostringstream out; + out << "Scanning " << all_ltis.size() << " LTIs for redundancy...\n"; + + soar_module::sqlite_statement* expand_q = SQL->web_expand; + + // Track domination relationships: dominated_lti -> dominator_lti + std::map dominated_by; + size_t pairs_checked = 0; + + for (size_t i = 0; i < all_ltis.size(); i++) + { + uint64_t lti_a = all_ltis[i]; + + // Skip if already dominated + if (dominated_by.count(lti_a)) continue; + + for (size_t j = 0; j < all_ltis.size(); j++) + { + if (i == j) continue; + + uint64_t lti_b = all_ltis[j]; + + // Skip if B is already dominated by A (transitivity) + if (dominated_by.count(lti_b) && dominated_by[lti_b] == lti_a) continue; + + // Skip if A is already dominated + if (dominated_by.count(lti_a)) break; + + pairs_checked++; + + // Check if A includes B (B is dominated by A) + if (smem_lti_includes(this, expand_q, lti_a, lti_b)) + { + // B is dominated by A, but only if B doesn't also include A + // (which would mean they're equivalent -- report the one with lower ID as dominator) + if (!smem_lti_includes(this, expand_q, lti_b, lti_a)) + { + dominated_by[lti_b] = lti_a; + } + else if (lti_a < lti_b) + { + // Equivalent structures -- lower ID dominates + dominated_by[lti_b] = lti_a; + } + } + } + } + + out << "Checked " << pairs_checked << " pairs.\n\n"; + + if (dominated_by.empty()) + { + out << "No redundant LTIs found.\n"; + } + else + { + out << "Dominated LTIs:\n"; + for (auto& entry : dominated_by) + { + out << " @" << entry.first << " is dominated by @" << entry.second << "\n"; + } + out << "\n" << dominated_by.size() << " redundant LTI(s) found.\n"; + } + + result.append(out.str()); + return true; +} diff --git a/Core/SoarKernel/src/semantic_memory/smem_settings.cpp b/Core/SoarKernel/src/semantic_memory/smem_settings.cpp index dd8c7cb5f0..858f0b93d1 100644 --- a/Core/SoarKernel/src/semantic_memory/smem_settings.cpp +++ b/Core/SoarKernel/src/semantic_memory/smem_settings.cpp @@ -338,6 +338,7 @@ void smem_param_container::print_settings(agent* thisAgent) outputManager->printa_sf(thisAgent, "%s %-%s\n", concatJustified("smem --init ","", 55).c_str(), "Reinitialize semantic memory store"); outputManager->printa_sf(thisAgent, "%s %-%s\n", concatJustified("smem --query ","{(cue)* []}", 55).c_str(), "Query for concepts in semantic store matching cue"); outputManager->printa_sf(thisAgent, "%s %-%s\n", concatJustified("smem --remove","{ (id [^attr [value]])* }", 55).c_str(), "Remove semantic memory structures"); + outputManager->printa_sf(thisAgent, "%s %-%s\n", concatJustified("smem --redundancy-check","", 55).c_str(), "Find dominated LTIs (experimental)"); outputManager->printa(thisAgent, "------------------------ Printing ---------------------\n"); outputManager->printa_sf(thisAgent, "%s %-%s\n", concatJustified("print","@", 55).c_str(), "Print all of semantic memory"); outputManager->printa_sf(thisAgent, "%s %-%s\n", concatJustified("print","", 55).c_str(), "Print specific semantic memory"); diff --git a/UnitTests/SoarTestAgents/epmem/EpMemFunctionalTests_testConsolidation.soar b/UnitTests/SoarTestAgents/epmem/EpMemFunctionalTests_testConsolidation.soar new file mode 100644 index 0000000000..4ea46beb06 --- /dev/null +++ b/UnitTests/SoarTestAgents/epmem/EpMemFunctionalTests_testConsolidation.soar @@ -0,0 +1,47 @@ +# Test episodic-to-semantic memory consolidation +# Enable epmem with dc trigger +epmem --set trigger dc +epmem --set learning on + +# Enable smem +smem --set learning on + +# Enable consolidation with short interval for testing +epmem --set consolidate on +epmem --set consolidate-interval 15 +epmem --set consolidate-threshold 10 + +### Initialize: create a stable structure that will persist +sp {propose*init + (state ^superstate nil + -^name) +--> + ( ^operator +) + ( ^name init) +} + +sp {apply*init + (state ^operator.name init) +--> + ( ^name consolidation-test + ^item + ^counter 0) + ( ^color red ^shape circle) +} + +### Count decision cycles to keep the agent running +sp {propose*count + (state ^name consolidation-test + ^counter ) +--> + ( ^operator + =) + ( ^name count) +} + +sp {apply*count + (state ^operator.name count + ^counter ) +--> + ( ^counter -) + ( ^counter (+ 1)) +} diff --git a/UnitTests/SoarTestAgents/epmem/EpMemFunctionalTests_testConsolidationEviction.soar b/UnitTests/SoarTestAgents/epmem/EpMemFunctionalTests_testConsolidationEviction.soar new file mode 100644 index 0000000000..e86580408b --- /dev/null +++ b/UnitTests/SoarTestAgents/epmem/EpMemFunctionalTests_testConsolidationEviction.soar @@ -0,0 +1,50 @@ +# Test episodic memory eviction after consolidation +# Enable epmem with dc trigger +epmem --set trigger dc +epmem --set learning on + +# Enable smem +smem --set learning on + +# Enable consolidation with short interval for testing +epmem --set consolidate on +epmem --set consolidate-interval 15 +epmem --set consolidate-threshold 10 + +# Enable eviction: episodes older than 12 cycles get evicted +epmem --set consolidate-evict-age 12 + +### Initialize: create a stable structure that will persist +sp {propose*init + (state ^superstate nil + -^name) +--> + ( ^operator +) + ( ^name init) +} + +sp {apply*init + (state ^operator.name init) +--> + ( ^name eviction-test + ^item + ^counter 0) + ( ^color red ^shape circle) +} + +### Count decision cycles to keep the agent running +sp {propose*count + (state ^name eviction-test + ^counter ) +--> + ( ^operator + =) + ( ^name count) +} + +sp {apply*count + (state ^operator.name count + ^counter ) +--> + ( ^counter -) + ( ^counter (+ 1)) +} diff --git a/UnitTests/SoarUnitTests/EpMemFunctionalTests.cpp b/UnitTests/SoarUnitTests/EpMemFunctionalTests.cpp index ac4e95d2bb..2edddf7755 100644 --- a/UnitTests/SoarUnitTests/EpMemFunctionalTests.cpp +++ b/UnitTests/SoarUnitTests/EpMemFunctionalTests.cpp @@ -303,6 +303,50 @@ void EpMemFunctionalTests::testEpmemUnit_14() runTest("epmem_unit_test_14", 113); } +void EpMemFunctionalTests::testConsolidation() +{ + runTestSetup("testConsolidation"); + agent->RunSelf(25); + + // Verify consolidation wrote to smem + std::string smemResult = agent->ExecuteCommandLine("p @"); + assertTrue_msg("SMem should contain consolidated entries after 25 cycles:\n" + smemResult, + smemResult.find("red") != std::string::npos); +} + +void EpMemFunctionalTests::testConsolidationOff() +{ + runTestSetup("testConsolidation"); + agent->ExecuteCommandLine("epmem --set consolidate off"); + agent->RunSelf(25); + + // Verify smem has no consolidated entries when consolidation is off + std::string smemResult = agent->ExecuteCommandLine("p @"); + assertTrue_msg("SMem should not contain 'red' with consolidation off", + smemResult.find("red") == std::string::npos); +} + +void EpMemFunctionalTests::testConsolidationEviction() +{ + runTestSetup("testConsolidationEviction"); + agent->RunSelf(25); + + // Verify consolidation still wrote to smem + std::string smemResult = agent->ExecuteCommandLine("p @"); + assertTrue_msg("SMem should contain consolidated entries after 25 cycles:\n" + smemResult, + smemResult.find("red") != std::string::npos); + + // Verify old episodes were evicted (episode 1 should be gone, evict_before = 25 - 12 = 13) + std::string ep1 = agent->ExecuteCommandLine("epmem --print 1"); + assertTrue_msg("Episode 1 should have been evicted:\n" + ep1, + ep1.find("Episode 1") == std::string::npos); + + // Verify recent episodes still exist (episode 20 should still be there) + std::string ep20 = agent->ExecuteCommandLine("epmem --print 20"); + assertTrue_msg("Episode 20 should still exist:\n" + ep20, + ep20.find("Episode 20") != std::string::npos); +} + void EpMemFunctionalTests::testEpMemSmemFactorizationCombinationTest() { runTestSetup("testSMemEpMemFactorization"); diff --git a/UnitTests/SoarUnitTests/EpMemFunctionalTests.hpp b/UnitTests/SoarUnitTests/EpMemFunctionalTests.hpp index 8c50d59e25..968da06977 100644 --- a/UnitTests/SoarUnitTests/EpMemFunctionalTests.hpp +++ b/UnitTests/SoarUnitTests/EpMemFunctionalTests.hpp @@ -58,8 +58,11 @@ class EpMemFunctionalTests : public FunctionalTestHarness { TEST(testWMELength_FiveCycle, -1) TEST(testWMELength_InfiniteCycle, -1) TEST(testWMELength_MultiCycle, -1) - TEST(testWMELength_OneCycle, -1); - + TEST(testWMELength_OneCycle, -1) + TEST(testConsolidation, -1) + TEST(testConsolidationOff, -1) + TEST(testConsolidationEviction, -1); + void testAfterEpMem(); void testAllNegQueriesEpMem(); void testBeforeAfterProhibitEpMem(); @@ -105,6 +108,9 @@ class EpMemFunctionalTests : public FunctionalTestHarness { void testWMELength_InfiniteCycle(); void testWMELength_MultiCycle(); void testWMELength_OneCycle(); + void testConsolidation(); + void testConsolidationOff(); + void testConsolidationEviction(); void after(bool caught) { tearDown(caught); } void tearDown(bool caught);