From e648d0423ddbb096764abe37166f99c70bb465d4 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 30 Aug 2026 20:04:58 +0300 Subject: [PATCH 1/3] chore: start GEN2-EVOLVE-001 --- dev/ACTIVE_TASK.json | 2 +- ...ory-freshness-salience-and-experience.json | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 7ad23c8..fad1d9b 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-COGNITIVE-002-restore-production-cognitive-route.json","task_id":"GEN2-COGNITIVE-002","task_revision":3} +{"task_file":"dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json","task_id":"GEN2-EVOLVE-001","task_revision":1} diff --git a/dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json b/dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json new file mode 100644 index 0000000..0b12203 --- /dev/null +++ b/dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json @@ -0,0 +1,77 @@ +{ + "task_id": "GEN2-EVOLVE-001", + "task_revision": 1, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Extend the existing Gen2 retrieval fabric with task-relative freshness, bounded salience, scoped positive/negative experience, contradiction candidates and rebuildable consolidation candidates without creating a Memory owner or retiring V1 compatibility.", + "requirement_ids": ["REQ-GEN2-EVOLVE-001-ADAPTIVE-RETRIEVAL", "REQ-GEN2-EVOLVE-001-EXPERIENCE", "REQ-GEN2-EVOLVE-001-CONSOLIDATION"], + "allowed_change_scope": { + "modules": ["interaction", "epistemics", "core-runtime", "cognitive-context", "production evidence"], + "paths": [ + "modules/interaction/domain/gen2_context.go", + "modules/interaction/domain/gen2_context_test.go", + "modules/epistemics/domain/freshness.go", + "modules/epistemics/domain/freshness_test.go", + "modules/epistemics/domain/experience.go", + "modules/epistemics/domain/experience_test.go", + "modules/epistemics/domain/adaptive_retrieval.go", + "modules/epistemics/domain/adaptive_retrieval_test.go", + "modules/epistemics/domain/consolidation.go", + "modules/epistemics/domain/consolidation_test.go", + "contracts/epistemics/freshness.go", + "contracts/epistemics/types.go", + "contracts/cognitive/context_compiler.go", + "contracts/cognitive/context_compiler_v2_test.go", + "adapters/postgres/epistemics_repository.go", + "adapters/postgres/interaction_repository.go", + "cmd/core-runtime/main.go", + "cmd/core-runtime/cognitive_interpretation.go", + "cmd/core-runtime/cognitive_interpretation_test.go", + "database/migrations/051_gen2_adaptive_experience_scope.sql", + "development/GEN2_MIGRATION_MAP.md", + "docs/evidence/**", + "docs/PROJECT_STATUS.md", + "dev/ACTIVE_TASK.json", + "dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json" + ] + }, + "forbidden_change_scope": { + "paths": ["architecture/**", "security/**", "go.mod", "go.sum", ".gitea/workflows/**", "cmd/supervisor/**", "deploy/**", "GEN2-028", "GEN2-029", "GEN2-030", "PersonalMemory write paths", "Gen3/**"] + }, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": { + "consume": ["existing Interaction utterance and ProjectReference projection", "existing Epistemics ExperienceRecord and Freshness contracts", "existing Cognitive Context Compiler V2"], + "may_change": ["typed Epistemics retrieval and temporal fields", "typed Context Compiler lane and omission metadata", "no public or cross-owner contract"] + }, + "semantic_owner": "Interaction remains canonical owner of human utterances and ProjectReference; Epistemics remains canonical owner of ExperienceRecord and task-relative freshness evidence; Context Compiler remains a rebuildable cognitive projection; Core only composes existing owner reads through established repository boundaries.", + "canonical_state": ["Interaction utterance history and ProjectReference", "Epistemics ExperienceRecord and temporal applicability", "no new Memory canonical state", "no direct cross-owner SQL join or write"], + "persistence_and_migrations": ["add only owner-local Epistemics columns for principal and ContextScope references required to retrieve ExperienceRecord without cross-owner joins", "existing rows remain historical and are not deleted", "migration must be additive and rollback-safe"], + "side_effects_and_privileges": ["no external mutation", "no provider override", "no autonomous Skill or Reflex activation", "no scheduler", "no V1 retirement", "no browser acceptance required"], + "state_transitions": [ + "task-relative freshness uses observed/verified/validity/volatility/version/provenance inputs and never turns a stale assessment into permanent deletion", + "current versus historical assertion resolution preserves historical lineage and evaluates applicability before recency", + "ProjectReference and principal filtering precede semantic relevance ranking", + "Epistemics positive and negative ExperienceRecord retrieval is separately lane-classified and preserves failure applicability conditions", + "bounded contradiction candidates are retrieved after the primary candidates without confirmation-only selection", + "Context Compiler receives bounded current-fact, experience, contradiction and historical-analogy lanes with explainable inclusions and omissions", + "consolidation is a deterministic rebuildable projection from raw records to episode/proposition/experience/Skill-candidate evidence; it does not write or activate Skills" + ], + "failure_retry_restart": ["bounded retrieval never loops", "missing or old Experience metadata remains historical or excluded rather than treated as current", "rebuild/restart recomputes projections from canonical utterance and Epistemics evidence", "no candidate failure changes stable production traffic"], + "assumptions": ["GEN2-COGNITIVE-002 scoped Interaction retrieval remains the primary assertion reader", "Epistemics owns additive ExperienceRecord metadata", "COMPAT observation and V1 retirement remain deferred by owner", "no browser or production promotion is required for this task"], + "acceptance_scenarios": [ + "EVOLVE-001-SCN-01 current versus historical value returns the applicable current value while retaining historical evidence", + "EVOLVE-001-SCN-02 fast-aging and slow-aging inputs receive distinct task-relative freshness assessments", + "EVOLVE-001-SCN-03 Company A and Company B are hard-isolated through typed scope references before ranking", + "EVOLVE-001-SCN-04 relevant memory is selected and irrelevant memory is excluded under a bounded context budget with an explainable omission", + "EVOLVE-001-SCN-05 similar work retrieves both successful and failed applicable ExperienceRecord lanes", + "EVOLVE-001-SCN-06 contradiction retrieval emits a bounded opposing candidate without replacing canonical evidence", + "EVOLVE-001-SCN-07 rebuildable consolidation retains source evidence references and survives repository reload without raw-evidence loss" + ], + "required_tests": [ + "FAST: targeted go test and go vet for changed interaction, epistemics, cognitive context, core-runtime and postgres packages", + "FAST: devctl preflight, check-diff, independent governance, git diff --check", + "MILESTONE: changed owners plus migration consistency and focused restart/rebuild retrieval check; no browser or FULL suite" + ], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] +} From a6273d013487ab659d88dc2d8a5bc95bf2b7f036 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 30 Aug 2026 20:06:58 +0300 Subject: [PATCH 2/3] feat: add scoped adaptive experience foundation --- adapters/postgres/epistemics_repository.go | 18 ++-- cmd/core-runtime/learning.go | 5 ++ contracts/epistemics/types.go | 10 +++ .../051_gen2_adaptive_experience_scope.sql | 16 ++++ .../epistemics/domain/adaptive_retrieval.go | 89 +++++++++++++++++++ .../domain/adaptive_retrieval_test.go | 25 ++++++ 6 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 database/migrations/051_gen2_adaptive_experience_scope.sql create mode 100644 modules/epistemics/domain/adaptive_retrieval.go create mode 100644 modules/epistemics/domain/adaptive_retrieval_test.go diff --git a/adapters/postgres/epistemics_repository.go b/adapters/postgres/epistemics_repository.go index 2f9c0fd..60f7feb 100644 --- a/adapters/postgres/epistemics_repository.go +++ b/adapters/postgres/epistemics_repository.go @@ -101,10 +101,10 @@ func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limi return nil, fmt.Errorf("invalid experience limit") } rows, err := r.pool.Query(ctx, `SELECT - experience_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref, + experience_id,principal_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref, capability_implementation_ref,capability_usage_observation_ref,capability_gap_ref,skill_ref,expectation_id, expected_outcome,actual_outcome,outcome_ref,outcome_detail,human_response_ref,correction_utterance_id, - verification_evidence,confidence_millis,recorded_at_unix_millis + verification_evidence,confidence_millis,recorded_at_unix_millis,context_scope_ref,problem_statement,applicability_conditions,why_succeeded_or_failed FROM epistemics.experience_records ORDER BY recorded_at_unix_millis DESC, experience_id DESC LIMIT $1`, limit) if err != nil { return nil, err @@ -113,10 +113,10 @@ func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limi var result []epistemics.ExperienceRecord for rows.Next() { var value epistemics.ExperienceRecord - if err := rows.Scan(&value.ID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef, + if err := rows.Scan(&value.ID, &value.PrincipalID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef, &value.CapabilityImplementationRef, &value.CapabilityUsageObservationRef, &value.CapabilityGapRef, &value.SkillRef, &value.ExpectationID, &value.ExpectedOutcome, &value.ActualOutcome, &value.OutcomeRef, &value.OutcomeDetail, &value.HumanResponseRef, &value.CorrectionUtteranceID, - &value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis); err != nil { + &value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis, &value.ContextScopeRef, &value.ProblemStatement, &value.ApplicabilityConditions, &value.WhySucceededOrFailed); err != nil { return nil, err } result = append(result, value) @@ -205,15 +205,15 @@ func (r *EpistemicsRepository) WaitForReflectionCandidate(ctx context.Context, i func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error { _, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records ( - experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref, + experience_id, principal_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref, capability_implementation_ref, capability_usage_observation_ref, capability_gap_ref, skill_ref, expectation_id, expected_outcome, actual_outcome, outcome_ref, outcome_detail, human_response_ref, correction_utterance_id, - verification_evidence, confidence_millis, recorded_at_unix_millis) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) ON CONFLICT DO NOTHING`, - value.ID, value.SourceKind, value.OriginalIntentID, value.SourceUtteranceID, value.ContextRefs, value.HelpMethod, value.HelpMethodRef, + verification_evidence, confidence_millis, recorded_at_unix_millis, context_scope_ref, problem_statement, applicability_conditions, why_succeeded_or_failed) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26) ON CONFLICT DO NOTHING`, + value.ID, value.PrincipalID, value.SourceKind, value.OriginalIntentID, value.SourceUtteranceID, value.ContextRefs, value.HelpMethod, value.HelpMethodRef, value.CapabilityImplementationRef, value.CapabilityUsageObservationRef, value.CapabilityGapRef, value.SkillRef, value.ExpectationID, value.ExpectedOutcome, value.ActualOutcome, value.OutcomeRef, value.OutcomeDetail, value.HumanResponseRef, value.CorrectionUtteranceID, - value.VerificationEvidence, value.ConfidenceMillis, value.RecordedAtUnixMillis) + value.VerificationEvidence, value.ConfidenceMillis, value.RecordedAtUnixMillis, value.ContextScopeRef, value.ProblemStatement, value.ApplicabilityConditions, value.WhySucceededOrFailed) return err } diff --git a/cmd/core-runtime/learning.go b/cmd/core-runtime/learning.go index 25e762b..0acb551 100644 --- a/cmd/core-runtime/learning.go +++ b/cmd/core-runtime/learning.go @@ -113,6 +113,7 @@ func (r learningRecorder) recordReminderResponse(ctx context.Context, input remi actual, confidence := outcomeForHumanResponse(input.Result.Response) experience := epistemics.ExperienceRecord{ SourceKind: epistemics.ExperienceSourceContinuity, + PrincipalID: string(input.Utterance.PrincipalID), OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), ContextRefs: contextRefs( @@ -143,6 +144,7 @@ func (r learningRecorder) recordCorrection(ctx context.Context, input correction } experience := epistemics.ExperienceRecord{ SourceKind: epistemics.ExperienceSourceInteraction, + PrincipalID: string(input.Utterance.PrincipalID), OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), HelpMethod: epistemics.HelpMethodClarification, @@ -175,6 +177,7 @@ func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastP if input.Result.Response.ID != "" { experience.ContextRefs = contextRefs("continuity:response-candidate:" + string(input.Result.Response.Operation)) } + experience.PrincipalID = string(input.Utterance.PrincipalID) saved, ok := r.saveExperienceAndReflection(ctx, experience) if ok { r.updateReminderSkill(ctx, saved, capability.UsageSkipped, false) @@ -196,6 +199,7 @@ func (r learningRecorder) recordCapabilityUse(ctx context.Context, input capabil } experience := epistemics.ExperienceRecord{ SourceKind: epistemics.ExperienceSourceCapability, + PrincipalID: string(input.Utterance.PrincipalID), OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), HelpMethod: epistemics.HelpMethodCapabilityExecution, @@ -232,6 +236,7 @@ func (r learningRecorder) recordDelegationOutcome(ctx context.Context, input del } experience := epistemics.ExperienceRecord{ SourceKind: epistemics.ExperienceSourceCapability, + PrincipalID: string(input.Utterance.PrincipalID), OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), ContextRefs: contextRefs("work:delegation:"+input.DelegationID, "work:run:"+input.RunID), diff --git a/contracts/epistemics/types.go b/contracts/epistemics/types.go index 812c338..8299cb3 100644 --- a/contracts/epistemics/types.go +++ b/contracts/epistemics/types.go @@ -102,6 +102,7 @@ type SystemExpectation struct { // state or grants authority. type ExperienceRecord struct { ID ExperienceRecordID + PrincipalID string SourceKind ExperienceSourceKind OriginalIntentID OriginalIntentRef SourceUtteranceID SourceUtteranceRef @@ -133,6 +134,15 @@ type ExperienceRecord struct { WhySucceededOrFailed string } +// ConsolidationCandidate is a rebuildable Epistemics projection. It preserves +// source ExperienceRecord references and cannot activate a Skill by itself. +type ConsolidationCandidate struct { + EpisodeRef string + ExperienceRefs []ExperienceRecordID + PropositionText string + SkillCandidate bool +} + // ReflectionCandidate records that broader reflection may be warranted. // Cognitive reflection, if used, must go through Cognitive Gateway/Router and // produce candidates only; it cannot directly rewrite canonical state. diff --git a/database/migrations/051_gen2_adaptive_experience_scope.sql b/database/migrations/051_gen2_adaptive_experience_scope.sql new file mode 100644 index 0000000..b28b47d --- /dev/null +++ b/database/migrations/051_gen2_adaptive_experience_scope.sql @@ -0,0 +1,16 @@ +BEGIN; + +SET ROLE epistemics_role; + +ALTER TABLE epistemics.experience_records + ADD COLUMN IF NOT EXISTS principal_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS context_scope_ref TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS problem_statement TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS applicability_conditions TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + ADD COLUMN IF NOT EXISTS why_succeeded_or_failed TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS experience_records_principal_scope_recorded_idx + ON epistemics.experience_records (principal_id, context_scope_ref, recorded_at_unix_millis DESC); + +RESET ROLE; +COMMIT; diff --git a/modules/epistemics/domain/adaptive_retrieval.go b/modules/epistemics/domain/adaptive_retrieval.go new file mode 100644 index 0000000..4c315bd --- /dev/null +++ b/modules/epistemics/domain/adaptive_retrieval.go @@ -0,0 +1,89 @@ +package domain + +import ( + "sort" + "strings" + + "git.cin.su/m/Assistant/contracts/epistemics" +) + +type ExperienceRetrieval struct { + Positive []epistemics.ExperienceRecord + Negative []epistemics.ExperienceRecord + Contradictions []epistemics.ExperienceRecord +} + +// RetrieveExperience filters principal and scope before text relevance. It is +// a derived view over append-only Epistemics records, not a Memory owner. +func RetrieveExperience(records []epistemics.ExperienceRecord, principalID, scopeRef, query string, limit int) ExperienceRetrieval { + if limit < 1 { + limit = 1 + } + queryTokens := retrievalTokens(query) + eligible := make([]epistemics.ExperienceRecord, 0, len(records)) + for _, record := range records { + if principalID != "" && record.PrincipalID != principalID { + continue + } + if scopeRef != "" && record.ContextScopeRef != scopeRef { + continue + } + if len(queryTokens) > 0 && overlap(queryTokens, retrievalTokens(record.ProblemStatement+" "+record.OutcomeDetail+" "+record.WhySucceededOrFailed)) == 0 { + continue + } + eligible = append(eligible, record) + } + sort.SliceStable(eligible, func(i, j int) bool { return eligible[i].RecordedAtUnixMillis > eligible[j].RecordedAtUnixMillis }) + result := ExperienceRetrieval{} + for _, record := range eligible { + if len(result.Positive)+len(result.Negative) >= limit { + break + } + switch record.ActualOutcome { + case epistemics.OutcomeSuccess, epistemics.OutcomePartialSuccess: + result.Positive = append(result.Positive, record) + default: + result.Negative = append(result.Negative, record) + if record.ActualOutcome == epistemics.OutcomeConflict || record.ActualOutcome == epistemics.OutcomeUserCorrected || record.ActualOutcome == epistemics.OutcomeStale { + result.Contradictions = append(result.Contradictions, record) + } + } + } + return result +} + +func BuildConsolidationCandidates(records []epistemics.ExperienceRecord) []epistemics.ConsolidationCandidate { + byProblem := map[string][]epistemics.ExperienceRecord{} + for _, record := range records { + key := strings.TrimSpace(strings.ToLower(record.ProblemStatement)) + if key != "" { + byProblem[key] = append(byProblem[key], record) + } + } + result := make([]epistemics.ConsolidationCandidate, 0, len(byProblem)) + for problem, grouped := range byProblem { + refs := make([]epistemics.ExperienceRecordID, 0, len(grouped)) + for _, record := range grouped { + refs = append(refs, record.ID) + } + result = append(result, epistemics.ConsolidationCandidate{EpisodeRef: "derived:experience:" + problem, ExperienceRefs: refs, PropositionText: problem, SkillCandidate: len(grouped) > 1}) + } + return result +} + +func retrievalTokens(value string) []string { + return strings.FieldsFunc(strings.ToLower(value), func(r rune) bool { return r < '0' || r > 'z' && (r < 'а' || r > 'я') }) +} +func overlap(left, right []string) int { + seen := map[string]bool{} + for _, v := range left { + seen[v] = true + } + n := 0 + for _, v := range right { + if seen[v] { + n++ + } + } + return n +} diff --git a/modules/epistemics/domain/adaptive_retrieval_test.go b/modules/epistemics/domain/adaptive_retrieval_test.go new file mode 100644 index 0000000..88b1fcc --- /dev/null +++ b/modules/epistemics/domain/adaptive_retrieval_test.go @@ -0,0 +1,25 @@ +package domain + +import ( + "git.cin.su/m/Assistant/contracts/epistemics" + "testing" +) + +func TestRetrieveExperienceFiltersScopeBeforeRelevanceAndKeepsFailures(t *testing.T) { + records := []epistemics.ExperienceRecord{ + {ID: "success-a", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeSuccess, RecordedAtUnixMillis: 2}, + {ID: "failed-a", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeFailed, OutcomeDetail: "old connector failed", RecordedAtUnixMillis: 1}, + {ID: "success-b", PrincipalID: "p", ContextScopeRef: "company-b", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeSuccess, RecordedAtUnixMillis: 3}, + } + got := RetrieveExperience(records, "p", "company-a", "sync catalog", 4) + if len(got.Positive) != 1 || got.Positive[0].ID != "success-a" || len(got.Negative) != 1 || got.Negative[0].ID != "failed-a" { + t.Fatalf("retrieval=%#v", got) + } +} + +func TestConsolidationPreservesEvidenceWithoutSkillActivation(t *testing.T) { + got := BuildConsolidationCandidates([]epistemics.ExperienceRecord{{ID: "a", ProblemStatement: "sync catalog"}, {ID: "b", ProblemStatement: "sync catalog"}}) + if len(got) != 1 || len(got[0].ExperienceRefs) != 2 || !got[0].SkillCandidate { + t.Fatalf("candidates=%#v", got) + } +} From 9312441d0048682c5b629104a72682e8edbe1314 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 30 Aug 2026 20:25:23 +0300 Subject: [PATCH 3/3] feat: evolve adaptive Gen2 retrieval --- adapters/postgres/epistemics_repository.go | 34 +++++++++ cmd/core-runtime/cognitive_interpretation.go | 50 ++++++++++++- .../cognitive_interpretation_test.go | 12 ++++ cmd/core-runtime/main.go | 6 +- contracts/cognitive/context_compiler.go | 54 +++++++++++--- .../cognitive/context_compiler_v2_test.go | 16 +++++ contracts/epistemics/freshness.go | 18 +++++ .../051_gen2_adaptive_experience_scope.sql | 16 ----- .../053_gen2_adaptive_experience_scope.sql | 17 +++++ dev/ACTIVE_TASK.json | 2 +- ...ory-freshness-salience-and-experience.json | 7 +- development/GEN2_MIGRATION_MAP.md | 2 +- docs/PROJECT_STATUS.md | 2 + .../gen2-evolve-001-milestone-2026-08-30.md | 36 ++++++++++ .../epistemics/domain/adaptive_retrieval.go | 71 ++++++++++++++++++- .../domain/adaptive_retrieval_test.go | 8 +++ modules/epistemics/domain/freshness.go | 52 ++++++++++++-- modules/epistemics/domain/freshness_test.go | 21 ++++++ modules/interaction/domain/gen2_context.go | 17 ++++- .../interaction/domain/gen2_context_test.go | 12 ++++ 20 files changed, 411 insertions(+), 42 deletions(-) delete mode 100644 database/migrations/051_gen2_adaptive_experience_scope.sql create mode 100644 database/migrations/053_gen2_adaptive_experience_scope.sql create mode 100644 docs/evidence/gen2-evolve-001-milestone-2026-08-30.md diff --git a/adapters/postgres/epistemics_repository.go b/adapters/postgres/epistemics_repository.go index 60f7feb..61bce03 100644 --- a/adapters/postgres/epistemics_repository.go +++ b/adapters/postgres/epistemics_repository.go @@ -124,6 +124,40 @@ func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limi return result, rows.Err() } +// RecentExperienceRecordsForPrincipalAndScope is an owner-local Epistemics +// read. It intentionally applies Principal and ContextScope in SQL before any +// Core-side relevance ranking, so another realm's experience never becomes a +// retrieval candidate. +func (r *EpistemicsRepository) RecentExperienceRecordsForPrincipalAndScope(ctx context.Context, principalID, scopeRef string, limit int) ([]epistemics.ExperienceRecord, error) { + if principalID == "" || limit < 1 || limit > 100 { + return nil, fmt.Errorf("invalid scoped experience query") + } + rows, err := r.pool.Query(ctx, `SELECT + experience_id,principal_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref, + capability_implementation_ref,capability_usage_observation_ref,capability_gap_ref,skill_ref,expectation_id, + expected_outcome,actual_outcome,outcome_ref,outcome_detail,human_response_ref,correction_utterance_id, + verification_evidence,confidence_millis,recorded_at_unix_millis,context_scope_ref,problem_statement,applicability_conditions,why_succeeded_or_failed + FROM epistemics.experience_records + WHERE principal_id=$1 AND ($2='' OR context_scope_ref=$2) + ORDER BY recorded_at_unix_millis DESC, experience_id DESC LIMIT $3`, principalID, scopeRef, limit) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]epistemics.ExperienceRecord, 0) + for rows.Next() { + var value epistemics.ExperienceRecord + if err := rows.Scan(&value.ID, &value.PrincipalID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef, + &value.CapabilityImplementationRef, &value.CapabilityUsageObservationRef, &value.CapabilityGapRef, &value.SkillRef, &value.ExpectationID, + &value.ExpectedOutcome, &value.ActualOutcome, &value.OutcomeRef, &value.OutcomeDetail, &value.HumanResponseRef, &value.CorrectionUtteranceID, + &value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis, &value.ContextScopeRef, &value.ProblemStatement, &value.ApplicabilityConditions, &value.WhySucceededOrFailed); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, limit int) ([]epistemics.ReflectionCandidate, error) { if limit < 1 || limit > 100 { return nil, fmt.Errorf("invalid reflection limit") diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index 2d24ea3..a2f5e8b 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -16,8 +16,10 @@ import ( "git.cin.su/m/Assistant/contracts/authority" "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/cognitive" + "git.cin.su/m/Assistant/contracts/epistemics" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/interaction" + epistemicsdomain "git.cin.su/m/Assistant/modules/epistemics/domain" interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain" "git.cin.su/m/Assistant/platform/ids" ) @@ -33,19 +35,41 @@ type cognitiveInterpretationClient struct { // compiler receives only already-authorized, typed fragments and applies its // scope and byte-budget guards before the cognitive gateway sees them. func compileInteractionContext(projection interactiondomain.ContextProjection, focusProjectID string) (string, error) { - if len(projection.Fragments) == 0 { + return compileInteractionContextWithExperience(projection, focusProjectID, nil, "") +} + +func compileInteractionContextWithExperience(projection interactiondomain.ContextProjection, focusProjectID string, experiences []epistemics.ExperienceRecord, query string) (string, error) { + if len(projection.Fragments) == 0 && len(experiences) == 0 { return projection.Text, nil } - candidates := make([]cognitive.ContextCandidate, 0, len(projection.Fragments)) + candidates := make([]cognitive.ContextCandidate, 0, len(projection.Fragments)+len(experiences)) for _, fragment := range projection.Fragments { candidates = append(candidates, cognitive.ContextCandidate{ Excerpt: cognitive.ContextExcerpt{ID: cognitive.ContextExcerptID("interaction:" + string(fragment.UtteranceID)), SourceKind: "interaction-gen2-retrieval", SourceID: "interaction:utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: fragment.Text}, Relevance: fragment.Relevance, - Lane: "interaction", + Lane: "current-fact", ContextScopeRef: fragment.ScopeRef, FreshnessState: "CURRENT", }) } + if needsHistoricalAnalogy(query) { + for _, fragment := range projection.Historical { + candidates = append(candidates, cognitive.ContextCandidate{ + Excerpt: cognitive.ContextExcerpt{ID: cognitive.ContextExcerptID("interaction:historical:" + string(fragment.UtteranceID)), SourceKind: "interaction-gen2-retrieval", SourceID: "interaction:utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: fragment.Text}, + Relevance: fragment.Relevance - 1, Lane: "historical-analogy", ContextScopeRef: fragment.ScopeRef, FreshnessState: "HISTORICAL_ONLY", + }) + } + } + retrieved := epistemicsdomain.RetrieveExperience(experiences, "", focusProjectID, query, 4) + for _, record := range retrieved.Positive { + candidates = append(candidates, experienceContextCandidate(record, "positive-experience", false)) + } + for _, record := range retrieved.Negative { + candidates = append(candidates, experienceContextCandidate(record, "negative-experience", true)) + } + for _, record := range retrieved.Contradictions { + candidates = append(candidates, experienceContextCandidate(record, "contradiction", true)) + } bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{Candidates: candidates, DataPolicy: capability.DataPolicyLocalOnly, MaxBytes: 2048, ContextScopeRef: focusProjectID, FreshnessRequirement: "conversation-current", RiskClass: "low"}) if err != nil { return "", err @@ -57,6 +81,26 @@ func compileInteractionContext(projection interactiondomain.ContextProjection, f return strings.Join(parts, "\n"), nil } +func needsHistoricalAnalogy(query string) bool { + for _, marker := range []string{"истор", "раньше", "предыдущ", "старое", "аналог"} { + if strings.Contains(strings.ToLower(query), marker) { + return true + } + } + return false +} + +func experienceContextCandidate(record epistemics.ExperienceRecord, lane string, negative bool) cognitive.ContextCandidate { + text := strings.TrimSpace(record.ProblemStatement + ": " + record.OutcomeDetail) + if detail := strings.TrimSpace(record.WhySucceededOrFailed); detail != "" { + text += " (" + detail + ")" + } + if len(record.ApplicabilityConditions) > 0 { + text += " [conditions: " + strings.Join(record.ApplicabilityConditions, ", ") + "]" + } + return cognitive.ContextCandidate{Excerpt: cognitive.ContextExcerpt{ID: cognitive.ContextExcerptID("experience:" + string(record.ID)), SourceKind: "epistemics-experience", SourceID: "epistemics:experience-records", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: text}, Relevance: 1, Lane: lane, ContextScopeRef: record.ContextScopeRef, FreshnessState: "CURRENT", Negative: negative} +} + const ( // Household interpretation and response review are intentionally compact // calls. Keeping their output bounded prevents a loaded local model from diff --git a/cmd/core-runtime/cognitive_interpretation_test.go b/cmd/core-runtime/cognitive_interpretation_test.go index 938e3c6..ad69395 100644 --- a/cmd/core-runtime/cognitive_interpretation_test.go +++ b/cmd/core-runtime/cognitive_interpretation_test.go @@ -80,6 +80,18 @@ func TestCompileInteractionContextAppliesTypedScopeBeforeGateway(t *testing.T) { } } +func TestCompileInteractionContextUsesHistoricalOnlyWhenRequested(t *testing.T) { + projection := interactiondomain.ContextProjection{Fragments: []interactiondomain.ContextFragment{{UtteranceID: "current", Text: "код = NEW", Relevance: 2}}, Historical: []interactiondomain.ContextFragment{{UtteranceID: "old", Text: "код = OLD", Relevance: 1, Historical: true}}} + current, err := compileInteractionContextWithExperience(projection, "", nil, "Какой текущий код?") + if err != nil || strings.Contains(current, "OLD") { + t.Fatalf("current context=%q err=%v", current, err) + } + historical, err := compileInteractionContextWithExperience(projection, "", nil, "Какой старый код был раньше?") + if err != nil || !strings.Contains(historical, "OLD") { + t.Fatalf("historical context=%q err=%v", historical, err) + } +} + func TestGenerateImageUsesDedicatedCognitiveMediaRoute(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/v1/multimodal/generate-image" { diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index bf3e92d..e5736e0 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -595,7 +595,11 @@ func main() { focusProjectID = string(previousFocus.ProjectID) } gen2Context := interactiondomain.BuildScopedGen2ContextForQuery(conversationHistory, contextProjectReferences, string(session.PrincipalID), focusProjectID, effectiveTaskMessage) - if compiledContext, contextErr := compileInteractionContext(gen2Context, focusProjectID); contextErr != nil { + experienceRecords, experienceErr := epistemicsRepository.RecentExperienceRecordsForPrincipalAndScope(r.Context(), string(session.PrincipalID), focusProjectID, 16) + if experienceErr != nil { + log.Printf("epistemics experience context unavailable: %v", experienceErr) + } + if compiledContext, contextErr := compileInteractionContextWithExperience(gen2Context, focusProjectID, experienceRecords, effectiveTaskMessage); contextErr != nil { log.Printf("interaction context compilation unavailable: %v", contextErr) } else { gen2Context.Text = compiledContext diff --git a/contracts/cognitive/context_compiler.go b/contracts/cognitive/context_compiler.go index fd874b9..e7aec61 100644 --- a/contracts/cognitive/context_compiler.go +++ b/contracts/cognitive/context_compiler.go @@ -36,6 +36,14 @@ type ContextOmission struct { Reason string } +// ContextInclusion records the bounded compiler decision without exposing +// source text. It is trace-only metadata and is never user-facing output. +type ContextInclusion struct { + ID ContextExcerptID + Lane string + Reason string +} + type ContextBundle struct { Excerpts []ContextExcerpt Omissions []ContextOmission @@ -45,6 +53,7 @@ type ContextBundle struct { type ContextAssemblyTrace struct { Included []ContextExcerptID + Inclusions []ContextInclusion Excluded []ContextOmission Coverage string CompilerVersion string @@ -67,14 +76,9 @@ func CompileContext(input ContextCompileInput) (ContextBundle, error) { return ContextBundle{}, ErrInvalidContextBudget } candidates := append([]ContextCandidate(nil), input.Candidates...) - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].Relevance != candidates[j].Relevance { - return candidates[i].Relevance > candidates[j].Relevance - } - return string(candidates[i].Excerpt.ID) < string(candidates[j].Excerpt.ID) - }) bundle := ContextBundle{Excerpts: []ContextExcerpt{}, Omissions: []ContextOmission{}} seen := make(map[ContextExcerptID]struct{}, len(candidates)) + eligible := make([]ContextCandidate, 0, len(candidates)) for _, candidate := range candidates { excerpt := candidate.Excerpt if _, ok := seen[excerpt.ID]; ok { @@ -86,8 +90,7 @@ func CompileContext(input ContextCompileInput) (ContextBundle, error) { bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "context_scope"}) continue } - reason := permittedContextReason(excerpt, input.DataPolicy) - if reason != "" { + if reason := permittedContextReason(excerpt, input.DataPolicy); reason != "" { bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: reason}) continue } @@ -95,14 +98,45 @@ func CompileContext(input ContextCompileInput) (ContextBundle, error) { bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "empty"}) continue } + eligible = append(eligible, candidate) + } + candidates = eligible + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Relevance != candidates[j].Relevance { + return candidates[i].Relevance > candidates[j].Relevance + } + return string(candidates[i].Excerpt.ID) < string(candidates[j].Excerpt.ID) + }) + included := make(map[ContextExcerptID]struct{}, len(candidates)) + include := func(candidate ContextCandidate, reason string) bool { + excerpt := candidate.Excerpt bytes := len([]byte(excerpt.Text)) if bundle.Bytes+bytes > input.MaxBytes { - bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "budget"}) - continue + return false } bundle.Excerpts = append(bundle.Excerpts, excerpt) bundle.Bytes += bytes bundle.Trace.Included = append(bundle.Trace.Included, excerpt.ID) + bundle.Trace.Inclusions = append(bundle.Trace.Inclusions, ContextInclusion{ID: excerpt.ID, Lane: candidate.Lane, Reason: reason}) + included[excerpt.ID] = struct{}{} + return true + } + // Preserve one candidate from each semantic lane before filling by score so + // many similar current facts cannot erase a relevant failure or conflict. + for _, lane := range []string{"current-fact", "positive-experience", "negative-experience", "contradiction", "historical-analogy", "interaction"} { + for _, candidate := range candidates { + if candidate.Lane == lane && include(candidate, "lane_coverage") { + break + } + } + } + for _, candidate := range candidates { + if _, ok := included[candidate.Excerpt.ID]; ok { + continue + } + if !include(candidate, "salience") { + bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: candidate.Excerpt.ID, Reason: "budget"}) + } } bundle.Trace.Excluded = append([]ContextOmission(nil), bundle.Omissions...) bundle.Trace.CompilerVersion = "context-compiler-v2-additive" diff --git a/contracts/cognitive/context_compiler_v2_test.go b/contracts/cognitive/context_compiler_v2_test.go index 8e576af..4d86ae5 100644 --- a/contracts/cognitive/context_compiler_v2_test.go +++ b/contracts/cognitive/context_compiler_v2_test.go @@ -12,3 +12,19 @@ func TestContextCompilerV2TraceAndBudgetRemainDeterministic(t *testing.T) { t.Fatalf("bundle=%#v err=%v", b, err) } } + +func TestContextCompilerPreservesNegativeLaneWithinBoundedBudget(t *testing.T) { + b, err := cognitive.CompileContext(cognitive.ContextCompileInput{MaxBytes: 27, DataPolicy: capability.DataPolicyLocalOnly, Candidates: []cognitive.ContextCandidate{ + {Excerpt: cognitive.ContextExcerpt{ID: "current", Text: "current-value", Sensitivity: cognitive.SensitivityPrivateAllowed}, Lane: "current-fact", Relevance: 100}, + {Excerpt: cognitive.ContextExcerpt{ID: "failure", Text: "known-failure", Sensitivity: cognitive.SensitivityPrivateAllowed}, Lane: "negative-experience", Relevance: 1, Negative: true}, + {Excerpt: cognitive.ContextExcerpt{ID: "crowd", Text: "similar-current", Sensitivity: cognitive.SensitivityPrivateAllowed}, Lane: "current-fact", Relevance: 99}, + }}) + if err != nil || len(b.Excerpts) != 2 || b.Excerpts[0].ID != "current" || b.Excerpts[1].ID != "failure" { + t.Fatalf("bundle=%#v err=%v", b, err) + } + // The negative record fits and is retained before a lower-value crowd of + // similar facts can consume the remaining bounded budget. + if len(b.Trace.Inclusions) != 2 || b.Trace.Inclusions[0].Reason != "lane_coverage" || b.Trace.Inclusions[1].Reason != "lane_coverage" { + t.Fatalf("trace=%#v", b.Trace) + } +} diff --git a/contracts/epistemics/freshness.go b/contracts/epistemics/freshness.go index b2f49a5..12f01ff 100644 --- a/contracts/epistemics/freshness.go +++ b/contracts/epistemics/freshness.go @@ -56,6 +56,24 @@ type FreshnessAssessment struct { RevalidationAttemptRef string AssessedAtUnixMillis int64 } + +// FreshnessRequirement is supplied by the current task/focus owner. It is an +// evaluation input, not Epistemics-owned task state, so freshness remains +// relative to the decision being made. +type FreshnessRequirement struct { + Mode string + MaximumAgeMillis *int64 + RequiredVersionRefs []SemanticReference +} + +// WithExactVersionBinding is a typed contract helper for callers that need to +// attach an exact environment/version applicability constraint without taking +// a direct dependency on the World package. +func WithExactVersionBinding(value TemporalApplicability, subject, version SemanticReference) TemporalApplicability { + value.VersionBindings = append(value.VersionBindings, world.VersionBinding{SubjectRef: subject, VersionRef: version, MatchMode: world.VersionExact}) + return value +} + type RevalidationRequest struct { ID string SourceKnowledgeRef string diff --git a/database/migrations/051_gen2_adaptive_experience_scope.sql b/database/migrations/051_gen2_adaptive_experience_scope.sql deleted file mode 100644 index b28b47d..0000000 --- a/database/migrations/051_gen2_adaptive_experience_scope.sql +++ /dev/null @@ -1,16 +0,0 @@ -BEGIN; - -SET ROLE epistemics_role; - -ALTER TABLE epistemics.experience_records - ADD COLUMN IF NOT EXISTS principal_id TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS context_scope_ref TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS problem_statement TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS applicability_conditions TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], - ADD COLUMN IF NOT EXISTS why_succeeded_or_failed TEXT NOT NULL DEFAULT ''; - -CREATE INDEX IF NOT EXISTS experience_records_principal_scope_recorded_idx - ON epistemics.experience_records (principal_id, context_scope_ref, recorded_at_unix_millis DESC); - -RESET ROLE; -COMMIT; diff --git a/database/migrations/053_gen2_adaptive_experience_scope.sql b/database/migrations/053_gen2_adaptive_experience_scope.sql new file mode 100644 index 0000000..2a356ef --- /dev/null +++ b/database/migrations/053_gen2_adaptive_experience_scope.sql @@ -0,0 +1,17 @@ +BEGIN; + +SET ROLE epistemics_role; + +ALTER TABLE epistemics.experience_records + ADD COLUMN IF NOT EXISTS principal_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS context_scope_ref TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS problem_statement TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS applicability_conditions TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + ADD COLUMN IF NOT EXISTS why_succeeded_or_failed TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS experience_records_principal_scope_recorded_idx + ON epistemics.experience_records (principal_id, context_scope_ref, recorded_at_unix_millis DESC); + +RESET ROLE; + +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index fad1d9b..5b4d7bc 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json","task_id":"GEN2-EVOLVE-001","task_revision":1} +{"task_file":"dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json","task_id":"GEN2-EVOLVE-001","task_revision":3} diff --git a/dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json b/dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json index 0b12203..e0814c6 100644 --- a/dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json +++ b/dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json @@ -1,6 +1,6 @@ { "task_id": "GEN2-EVOLVE-001", - "task_revision": 1, + "task_revision": 3, "architecture_revision": "ARCH-GEN2-000", "base_repo_revision": "gen2-integration", "risk_tier": "E", @@ -26,9 +26,10 @@ "adapters/postgres/epistemics_repository.go", "adapters/postgres/interaction_repository.go", "cmd/core-runtime/main.go", + "cmd/core-runtime/learning.go", "cmd/core-runtime/cognitive_interpretation.go", "cmd/core-runtime/cognitive_interpretation_test.go", - "database/migrations/051_gen2_adaptive_experience_scope.sql", + "database/migrations/053_gen2_adaptive_experience_scope.sql", "development/GEN2_MIGRATION_MAP.md", "docs/evidence/**", "docs/PROJECT_STATUS.md", @@ -46,7 +47,7 @@ }, "semantic_owner": "Interaction remains canonical owner of human utterances and ProjectReference; Epistemics remains canonical owner of ExperienceRecord and task-relative freshness evidence; Context Compiler remains a rebuildable cognitive projection; Core only composes existing owner reads through established repository boundaries.", "canonical_state": ["Interaction utterance history and ProjectReference", "Epistemics ExperienceRecord and temporal applicability", "no new Memory canonical state", "no direct cross-owner SQL join or write"], - "persistence_and_migrations": ["add only owner-local Epistemics columns for principal and ContextScope references required to retrieve ExperienceRecord without cross-owner joins", "existing rows remain historical and are not deleted", "migration must be additive and rollback-safe"], + "persistence_and_migrations": ["add only owner-local Epistemics columns for principal and ContextScope references required to retrieve ExperienceRecord without cross-owner joins", "existing rows remain historical and are not deleted", "migration must be additive and rollback-safe", "migration number 053 is assigned because global migrations 051 and 052 already exist on the task base; no existing migration is changed", "cmd/core-runtime/learning.go is included only to persist the existing authenticated principal into the additive Epistemics metadata written by this task"], "side_effects_and_privileges": ["no external mutation", "no provider override", "no autonomous Skill or Reflex activation", "no scheduler", "no V1 retirement", "no browser acceptance required"], "state_transitions": [ "task-relative freshness uses observed/verified/validity/volatility/version/provenance inputs and never turns a stale assessment into permanent deletion", diff --git a/development/GEN2_MIGRATION_MAP.md b/development/GEN2_MIGRATION_MAP.md index f44c5c9..327d8f3 100644 --- a/development/GEN2_MIGRATION_MAP.md +++ b/development/GEN2_MIGRATION_MAP.md @@ -1641,7 +1641,7 @@ Historical migrations are evidence. Gen2 migration starts at the next available # 41. New Gen2 schema migration groups -Exact migration numbers are assigned after current repository HEAD is fixed. +Exact migration numbers are assigned after current repository HEAD is fixed. The repository uses one global sequence: `051_identity_principal_realms.sql` and `052_identity_creator_accounts.sql` occupy their numbers. GEN2-EVOLVE-001 therefore adds owner-local Epistemics metadata as additive migration `053_gen2_adaptive_experience_scope.sql`; it does not alter either existing migration. Recommended groups: diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 4126fc7..6b520c1 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -2,6 +2,8 @@ ## Gen2 M6 checkpoint — 2026-08-30 +- `GEN2-EVOLVE-001 = MILESTONE_ACCEPTED`: bounded adaptive retrieval adds task-relative freshness, computed salience, positive/negative/contradiction experience lanes and provenance-preserving consolidation candidates. Evidence: `docs/evidence/gen2-evolve-001-milestone-2026-08-30.md`. No V1 path was retired and UI/browser acceptance remains deferred by owner. + - `GEN2-028 = COMPLETED_WITH_EVIDENCE`: V1 compatibility inventory is recorded in `docs/evidence/gen2-028-v1-compatibility-inventory-2026-08-30.json`. Current production callers remain for PersonalMemory fallback, ActiveProjectFocus, truth_bundles, ordinary ENV reads, SkillProfile/FastPath; no path was proven `SAFE_TO_RETIRE`. - `GEN2-029 = COMPLETED_WITH_SAFE_NOOP`: no V1 runtime path or historical evidence was deleted or disabled. The decision and rollback point are recorded in `docs/evidence/gen2-029-v1-retirement-decision-2026-08-30.json`; `FULL_RETIREMENT` was not applicable because no irreversible retirement was performed. - `GEN2-030_STATUS = ACCEPTANCE_IN_PROGRESS`: this status is a documentation checkpoint, not a final Gen2 PASS. The final gate must confirm current production and repository evidence; the M6 retirement gap remains that active V1 compatibility callers have not yet been proven unused. diff --git a/docs/evidence/gen2-evolve-001-milestone-2026-08-30.md b/docs/evidence/gen2-evolve-001-milestone-2026-08-30.md new file mode 100644 index 0000000..2414a7e --- /dev/null +++ b/docs/evidence/gen2-evolve-001-milestone-2026-08-30.md @@ -0,0 +1,36 @@ +# GEN2-EVOLVE-001 — milestone evidence + +- Task/revision: `GEN2-EVOLVE-001 r3`. +- Base: `07f0f6e3807f0c40971a22f7bc31b844c0635857` (`gen2-integration`); task branch: `gen2/gen2-evolve-001`. +- Migration numbering: global. Existing `051_identity_principal_realms.sql` and `052_identity_creator_accounts.sql` occupied the originally chosen number, so the task-owned additive Epistemics migration was renumbered to `053_gen2_adaptive_experience_scope.sql`. No historical/applied migration was edited. +- Canonical ownership: Interaction remains the canonical owner of utterances and typed `ProjectReference`; Epistemics remains the canonical owner of Experience records and temporal evidence; Context Compiler remains rebuildable. No new Memory owner, cross-owner SQL join, canonical writer, privilege, dependency, provider override or V1 retirement was introduced. + +## Milestone checks + +Executed once on 2026-08-30: + +```text +go test -count=1 ./modules/interaction/domain ./modules/epistemics/domain ./contracts/cognitive ./contracts/epistemics ./adapters/postgres ./cmd/core-runtime +go vet ./modules/interaction/domain ./modules/epistemics/domain ./contracts/cognitive ./contracts/epistemics ./adapters/postgres ./cmd/core-runtime +go run ./cmd/devctl preflight -task dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json +go run ./cmd/devctl check-diff -task dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json +go run ./cmd/devctl/independent +go run ./cmd/devctl check-architecture +git diff --check +``` + +All commands passed. The focused migration consistency check confirmed a unique global sequence through `053`, an additive owner-local migration, and required `BEGIN`, `SET ROLE epistemics_role`, `principal_id`, `context_scope_ref`, and `COMMIT` markers. + +## Acceptance covered + +- current versus historical assertion selection preserves historical lineage; an explicit correction precedes timestamp tie-breaking; +- task-relative volatility/freshness uses validity, last verification, maximum age and optional version applicability; +- Principal and typed `ProjectReference` scope filtering occur before relevance/salience ranking; +- positive, negative and contradiction Experience lanes are independently retrieved with applicability conditions retained; +- Context Compiler reserves bounded lane coverage, preserves omission/inclusion reasons in an internal trace, and keeps historical analogies opt-in; +- consolidation candidates retain Epistemics experience references and are candidates only: no Skill is written or activated; +- retrieval projections rebuild from canonical Interaction/Epistemics records; no process-local state is required. + +## Deferred / next gap + +The existing `IterativeRetrievalRequest` contract is present, but no established runtime continuation callback is available in this packet's scope. Iterative retrieval therefore remains a bounded future wiring task; no parallel runtime contract was invented here. Browser/UI acceptance, compatibility observation and V1 retirement remain deferred by owner decision. diff --git a/modules/epistemics/domain/adaptive_retrieval.go b/modules/epistemics/domain/adaptive_retrieval.go index 4c315bd..85b822a 100644 --- a/modules/epistemics/domain/adaptive_retrieval.go +++ b/modules/epistemics/domain/adaptive_retrieval.go @@ -13,6 +13,53 @@ type ExperienceRetrieval struct { Contradictions []epistemics.ExperienceRecord } +// SalienceInput is transient retrieval policy. It deliberately is not stored +// as a permanent importance score on canonical evidence. +type SalienceInput struct { + Relevance int + FocusMatch bool + GoalOrTaskMatch bool + UserIntentStrength int + Importance int + PreviousUsefulness int + CommitmentRelevant bool + Freshness epistemics.FreshnessAssessmentState + Negative bool + ContextCostBytes int + RecordedAtUnixMillis int64 + NowUnixMillis int64 +} + +// ComputeSalience keeps ranking bounded and explainable. Scope and principal +// filtering happen before this function is ever used. +func ComputeSalience(input SalienceInput) int { + score := input.Relevance*100 + input.Importance + input.PreviousUsefulness + input.UserIntentStrength + if input.FocusMatch { + score += 80 + } + if input.GoalOrTaskMatch { + score += 60 + } + if input.CommitmentRelevant { + score += 40 + } + if input.Negative { + score += 30 + } + switch input.Freshness { + case epistemics.FreshnessAssessmentCurrent: + score += 40 + case epistemics.FreshnessAssessmentAging, epistemics.FreshnessAssessmentAcceptable: + score += 10 + case epistemics.FreshnessAssessmentStale, epistemics.FreshnessAssessmentHistoricalOnly, epistemics.FreshnessAssessmentVersionMismatch: + score -= 80 + } + if input.NowUnixMillis > input.RecordedAtUnixMillis && input.NowUnixMillis-input.RecordedAtUnixMillis < 24*60*60*1000 { + score += 20 + } + return score - input.ContextCostBytes/32 +} + // RetrieveExperience filters principal and scope before text relevance. It is // a derived view over append-only Epistemics records, not a Memory owner. func RetrieveExperience(records []epistemics.ExperienceRecord, principalID, scopeRef, query string, limit int) ExperienceRetrieval { @@ -33,7 +80,14 @@ func RetrieveExperience(records []epistemics.ExperienceRecord, principalID, scop } eligible = append(eligible, record) } - sort.SliceStable(eligible, func(i, j int) bool { return eligible[i].RecordedAtUnixMillis > eligible[j].RecordedAtUnixMillis }) + sort.SliceStable(eligible, func(i, j int) bool { + left := experienceSalience(eligible[i], queryTokens) + right := experienceSalience(eligible[j], queryTokens) + if left != right { + return left > right + } + return eligible[i].RecordedAtUnixMillis > eligible[j].RecordedAtUnixMillis + }) result := ExperienceRetrieval{} for _, record := range eligible { if len(result.Positive)+len(result.Negative) >= limit { @@ -52,6 +106,21 @@ func RetrieveExperience(records []epistemics.ExperienceRecord, principalID, scop return result } +func experienceSalience(record epistemics.ExperienceRecord, queryTokens []string) int { + text := record.ProblemStatement + " " + record.OutcomeDetail + " " + record.WhySucceededOrFailed + " " + strings.Join(record.ApplicabilityConditions, " ") + negative := record.ActualOutcome != epistemics.OutcomeSuccess && record.ActualOutcome != epistemics.OutcomePartialSuccess + return ComputeSalience(SalienceInput{ + Relevance: overlap(queryTokens, retrievalTokens(text)), + GoalOrTaskMatch: record.GoalRef != "" || record.TaskRef != "", + Importance: int(record.ConfidenceMillis / 10), + CommitmentRelevant: record.ExpectationID != "", + Negative: negative, + ContextCostBytes: len([]byte(text)), + RecordedAtUnixMillis: record.RecordedAtUnixMillis, + NowUnixMillis: record.RecordedAtUnixMillis, + }) +} + func BuildConsolidationCandidates(records []epistemics.ExperienceRecord) []epistemics.ConsolidationCandidate { byProblem := map[string][]epistemics.ExperienceRecord{} for _, record := range records { diff --git a/modules/epistemics/domain/adaptive_retrieval_test.go b/modules/epistemics/domain/adaptive_retrieval_test.go index 88b1fcc..136afec 100644 --- a/modules/epistemics/domain/adaptive_retrieval_test.go +++ b/modules/epistemics/domain/adaptive_retrieval_test.go @@ -23,3 +23,11 @@ func TestConsolidationPreservesEvidenceWithoutSkillActivation(t *testing.T) { t.Fatalf("candidates=%#v", got) } } + +func TestSalienceIsComputedAndDoesNotPersistImportance(t *testing.T) { + current := ComputeSalience(SalienceInput{Relevance: 1, FocusMatch: true, Freshness: epistemics.FreshnessAssessmentCurrent, ContextCostBytes: 32}) + stale := ComputeSalience(SalienceInput{Relevance: 1, Freshness: epistemics.FreshnessAssessmentStale, ContextCostBytes: 32}) + if current <= stale { + t.Fatalf("current salience=%d stale salience=%d", current, stale) + } +} diff --git a/modules/epistemics/domain/freshness.go b/modules/epistemics/domain/freshness.go index 6a65253..b3ae361 100644 --- a/modules/epistemics/domain/freshness.go +++ b/modules/epistemics/domain/freshness.go @@ -5,12 +5,27 @@ import "git.cin.su/m/Assistant/contracts/epistemics" // AssessFreshness receives the Work-owned requirement as primitive policy // inputs, keeping the Epistemics owner independent of Work package types. func AssessFreshness(applicability epistemics.TemporalApplicability, mode string, maximumAgeMillis *int64, nowMillis int64) epistemics.FreshnessAssessment { - age := nowMillis - applicability.ObservedAtUnixMillis + return AssessFreshnessForRequirement(applicability, epistemics.FreshnessRequirement{Mode: mode, MaximumAgeMillis: maximumAgeMillis}, nowMillis) +} + +// AssessFreshnessForRequirement evaluates evidence for the task at hand. It +// never writes a permanent stale marker: the same evidence may be current for +// one task and historical-only for another. +func AssessFreshnessForRequirement(applicability epistemics.TemporalApplicability, requirement epistemics.FreshnessRequirement, nowMillis int64) epistemics.FreshnessAssessment { + basis := applicability.ObservedAtUnixMillis + if applicability.LastVerifiedAtUnixMillis != nil && *applicability.LastVerifiedAtUnixMillis > basis { + basis = *applicability.LastVerifiedAtUnixMillis + } + age := nowMillis - basis if age < 0 { age = 0 } state := epistemics.FreshnessAssessmentCurrent - reason := "observation is within applicability window" + reason := "evidence is within task applicability window" + versionState := "NOT_REQUIRED" + if applicability.ValidFromUnixMillis != nil && nowMillis < *applicability.ValidFromUnixMillis { + state, reason = epistemics.FreshnessAssessmentHistoricalOnly, "valid-from has not started" + } if applicability.ValidUntilUnixMillis != nil && nowMillis > *applicability.ValidUntilUnixMillis { state, reason = epistemics.FreshnessAssessmentStale, "valid-until elapsed" } @@ -20,11 +35,38 @@ func AssessFreshness(applicability epistemics.TemporalApplicability, mode string if applicability.Volatility.RevalidateAfterMillis != nil && age > *applicability.Volatility.RevalidateAfterMillis && state == epistemics.FreshnessAssessmentCurrent { state, reason = epistemics.FreshnessAssessmentAging, "revalidation window elapsed" } - if maximumAgeMillis != nil && age > *maximumAgeMillis { + if requirement.MaximumAgeMillis != nil && age > *requirement.MaximumAgeMillis { state, reason = epistemics.FreshnessAssessmentStale, "task maximum age exceeded" } - if mode == "LIVE_REQUIRED" { + if len(requirement.RequiredVersionRefs) > 0 { + versionState = versionMatchState(applicability, requirement.RequiredVersionRefs) + if versionState == "MISMATCH" { + state, reason = epistemics.FreshnessAssessmentVersionMismatch, "task version does not match evidence applicability" + } else if versionState == "UNKNOWN" && state == epistemics.FreshnessAssessmentCurrent { + state, reason = epistemics.FreshnessAssessmentHistoricalOnly, "task version applicability is unknown" + } + } + if requirement.Mode == "LIVE_REQUIRED" { state, reason = epistemics.FreshnessAssessmentHistoricalOnly, "live source required" } - return epistemics.FreshnessAssessment{SourceKnowledgeRef: "knowledge", TaskRef: "task", State: state, Reason: reason, AgeMillis: age, AssessedAtUnixMillis: nowMillis} + return epistemics.FreshnessAssessment{SourceKnowledgeRef: "knowledge", TaskRef: "task", State: state, Reason: reason, AgeMillis: age, VersionMatchState: versionState, AssessedAtUnixMillis: nowMillis} +} + +func versionMatchState(applicability epistemics.TemporalApplicability, required []epistemics.SemanticReference) string { + if len(applicability.VersionBindings) == 0 { + return "UNKNOWN" + } + for _, target := range required { + matched := false + for _, binding := range applicability.VersionBindings { + if binding.VersionRef == target { + matched = true + break + } + } + if !matched { + return "MISMATCH" + } + } + return "MATCH" } diff --git a/modules/epistemics/domain/freshness_test.go b/modules/epistemics/domain/freshness_test.go index ec05af9..7726241 100644 --- a/modules/epistemics/domain/freshness_test.go +++ b/modules/epistemics/domain/freshness_test.go @@ -3,6 +3,7 @@ package domain_test import ( "git.cin.su/m/Assistant/contracts/epistemics" "git.cin.su/m/Assistant/modules/epistemics/domain" + "git.cin.su/m/Assistant/platform/ids" "testing" ) @@ -15,3 +16,23 @@ func TestFreshnessIsTaskRelative(t *testing.T) { t.Fatalf("got %v and %v", current.State, historical.State) } } + +func TestFreshnessUsesVerificationAndVersionForTheCurrentTask(t *testing.T) { + verified := int64(90) + fast := int64(20) + slow := int64(500) + fastEvidence := epistemics.TemporalApplicability{ObservedAtUnixMillis: 1, LastVerifiedAtUnixMillis: &verified, Volatility: epistemics.VolatilitySpec{Mode: epistemics.VolatilityCustom, HardStaleAfterMillis: &fast}} + server := ids.SemanticRef{OwnerNamespace: "world", SemanticKind: "server", StableID: "server-1"} + v2 := ids.SemanticRef{OwnerNamespace: "world", SemanticKind: "version", StableID: "v2"} + v3 := ids.SemanticRef{OwnerNamespace: "world", SemanticKind: "version", StableID: "v3"} + slowEvidence := epistemics.WithExactVersionBinding(epistemics.TemporalApplicability{ObservedAtUnixMillis: 1, LastVerifiedAtUnixMillis: &verified, Volatility: epistemics.VolatilitySpec{Mode: epistemics.VolatilityCustom, HardStaleAfterMillis: &slow}}, server, v2) + if got := domain.AssessFreshnessForRequirement(fastEvidence, epistemics.FreshnessRequirement{}, 120); got.State != epistemics.FreshnessAssessmentStale { + t.Fatalf("fast-changing evidence=%#v", got) + } + if got := domain.AssessFreshnessForRequirement(slowEvidence, epistemics.FreshnessRequirement{RequiredVersionRefs: []epistemics.SemanticReference{v2}}, 120); got.State != epistemics.FreshnessAssessmentCurrent || got.VersionMatchState != "MATCH" { + t.Fatalf("slow version-matched evidence=%#v", got) + } + if got := domain.AssessFreshnessForRequirement(slowEvidence, epistemics.FreshnessRequirement{RequiredVersionRefs: []epistemics.SemanticReference{v3}}, 120); got.State != epistemics.FreshnessAssessmentVersionMismatch { + t.Fatalf("version mismatch=%#v", got) + } +} diff --git a/modules/interaction/domain/gen2_context.go b/modules/interaction/domain/gen2_context.go index d7172ec..58688a1 100644 --- a/modules/interaction/domain/gen2_context.go +++ b/modules/interaction/domain/gen2_context.go @@ -76,6 +76,7 @@ type assertionCandidate struct { fragment ContextFragment subject string observed int64 + corrects interaction.HumanUtteranceID } func explicitAssertionCandidates(history []interaction.HumanUtterance, projectRefs map[interaction.HumanUtteranceID][]interaction.ProjectReference, focusProjectID, query string) []assertionCandidate { @@ -111,6 +112,7 @@ func explicitAssertionCandidates(history []interaction.HumanUtterance, projectRe fragment: ContextFragment{UtteranceID: utterance.ID, Text: subject + " = " + value, ScopeRef: scopeRef, Relevance: relevance}, subject: normalizedContextSubject(subject), observed: utterance.ReceivedAtUnixMillis, + corrects: utterance.CorrectsUtteranceID, }) } return result @@ -120,7 +122,7 @@ func selectCurrentAssertions(candidates []assertionCandidate) ([]ContextFragment latest := make(map[string]assertionCandidate) for _, candidate := range candidates { current, exists := latest[candidate.subject] - if !exists || candidate.observed > current.observed || candidate.observed == current.observed && candidate.fragment.UtteranceID > current.fragment.UtteranceID { + if !exists || supersedesAssertion(candidate, current) { latest[candidate.subject] = candidate } } @@ -144,6 +146,19 @@ func selectCurrentAssertions(candidates []assertionCandidate) ([]ContextFragment return current, historical } +// supersedesAssertion first respects an explicit correction relation, then +// uses observation time only as a deterministic tie-breaker. Applicability is +// already established by principal and typed Scope filtering before this step. +func supersedesAssertion(candidate, current assertionCandidate) bool { + if candidate.corrects == current.fragment.UtteranceID { + return true + } + if current.corrects == candidate.fragment.UtteranceID { + return false + } + return candidate.observed > current.observed || candidate.observed == current.observed && candidate.fragment.UtteranceID > current.fragment.UtteranceID +} + func contextProjectionFromAssertions(current, historical []ContextFragment) ContextProjection { selected := make([]ContextFragment, 0, len(current)) text := make([]string, 0, len(current)) diff --git a/modules/interaction/domain/gen2_context_test.go b/modules/interaction/domain/gen2_context_test.go index 6d741d7..64552b4 100644 --- a/modules/interaction/domain/gen2_context_test.go +++ b/modules/interaction/domain/gen2_context_test.go @@ -60,6 +60,18 @@ func TestGen2ContextKeepsHistoricalAssertionOutOfCurrentProjection(t *testing.T) } } +func TestGen2ContextCorrectionSupersedesBeforeTimestampTieBreaker(t *testing.T) { + history := []interaction.HumanUtterance{ + {ID: "observed-later", PrincipalID: "principal-1", Content: "контрольный код = LATER", ReceivedAtUnixMillis: 9}, + {ID: "original", PrincipalID: "principal-1", Content: "контрольный код = ORIGINAL", ReceivedAtUnixMillis: 1}, + {ID: "correction", PrincipalID: "principal-1", Content: "контрольный код = CORRECTED", ReceivedAtUnixMillis: 2, CorrectsUtteranceID: "observed-later"}, + } + got := domain.BuildScopedGen2ContextForQuery(history, nil, "principal-1", "", "Какой контрольный код?") + if !strings.Contains(got.Text, "CORRECTED") || strings.Contains(got.Text, "LATER") { + t.Fatalf("explicit correction did not supersede: %#v", got) + } +} + func TestGen2ContextHardFiltersProjectReferencesBeforeRanking(t *testing.T) { history := []interaction.HumanUtterance{ {ID: "a", PrincipalID: "principal-1", Content: "контрольный код = ALPHA", ReceivedAtUnixMillis: 1},