Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions AgentExperience.NET.sln
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Pos
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Postgres.Tests", "tests\AgentExperience.Storage.Postgres.Tests\AgentExperience.Storage.Postgres.Tests.csproj", "{86C76642-C4EB-49A8-9E4C-A885644EE49F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Postgres.Vectors", "src\AgentExperience.Storage.Postgres.Vectors\AgentExperience.Storage.Postgres.Vectors.csproj", "{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentExperience.Storage.Postgres.Vectors.Tests", "tests\AgentExperience.Storage.Postgres.Vectors.Tests\AgentExperience.Storage.Postgres.Vectors.Tests.csproj", "{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -143,6 +147,30 @@ Global
{86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x64.Build.0 = Release|Any CPU
{86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x86.ActiveCfg = Release|Any CPU
{86C76642-C4EB-49A8-9E4C-A885644EE49F}.Release|x86.Build.0 = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|x64.ActiveCfg = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|x64.Build.0 = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|x86.ActiveCfg = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Debug|x86.Build.0 = Debug|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|Any CPU.Build.0 = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|x64.ActiveCfg = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|x64.Build.0 = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|x86.ActiveCfg = Release|Any CPU
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E}.Release|x86.Build.0 = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|x64.ActiveCfg = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|x64.Build.0 = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|x86.ActiveCfg = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Debug|x86.Build.0 = Debug|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|Any CPU.Build.0 = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|x64.ActiveCfg = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|x64.Build.0 = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|x86.ActiveCfg = Release|Any CPU
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -157,5 +185,7 @@ Global
{9B771769-47C4-4EA1-8808-3E4728899A04} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{DFBB8572-3FF3-43F5-8A16-2ADB91C0E25E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{86C76642-C4EB-49A8-9E4C-A885644EE49F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{9E836167-D0C5-49CC-B1A7-1F364DAEDA9E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6DC7D06F-EB0C-42A5-ABFD-9A1B344FFC6A} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
910 changes: 898 additions & 12 deletions README.md

Large diffs are not rendered by default.

128 changes: 128 additions & 0 deletions src/AgentExperience.Abstractions/ConfidenceEvidence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
namespace AgentExperience.Abstractions;

/// <summary>
/// Which way a piece of confidence evidence points: the lesson worked again, or it did not. It is
/// deliberately not <see cref="CheckResult"/>: that verdict is about one required check inside one
/// verification round, while this is about the stored lesson itself being reused.
/// </summary>
public enum ConfidenceEvidenceKind
{
/// <summary>The lesson was reused and the reuse succeeded. Counts towards <c>S</c>.</summary>
Supporting,

/// <summary>The lesson was reused and the reuse did not hold. Counts towards <c>F</c>.</summary>
Contradicting,
}

/// <summary>
/// Who observed the evidence, which is what decides the independence key it is deduplicated on.
/// </summary>
public enum ConfidenceEvidenceSource
{
/// <summary>
/// A deterministic evaluator observed the reuse. Independence is keyed on the record, the run, and
/// the verification round, so one round of one run counts once however many times it is submitted.
/// </summary>
Machine,

/// <summary>
/// A human reviewer judged the reuse. Independence is keyed on the record, the reviewer, and the
/// run, so one reviewer's opinion about one run counts once however many times it is submitted. The
/// reviewer identity is the host's <see cref="AuthorizationContext.PrincipalId"/> and never
/// anything an agent supplied.
/// </summary>
Human,
}

/// <summary>
/// One evidence-based movement of a record's reuse confidence, carried on the
/// <see cref="LifecycleEvent"/> that applies it. Core computes every number here from the record it
/// read; a store persists them exactly as given and never derives a score of its own.
/// </summary>
/// <remarks>
/// <para>
/// <b>The score is a heuristic, not a probability.</b> It is
/// <c>(1 + S) / (2 + S + F)</c> -- Laplace's rule of succession over independent observations -- where
/// <c>S</c> counts independent accepted supporting validations (including the one the record was
/// finalized with) and <c>F</c> counts independent accepted contradictions. It is a monotone, bounded
/// summary of how often reuse held up, useful for ranking and for a floor; it is not calibrated
/// against anything, and nothing may present it as the probability that the next reuse succeeds.
/// </para>
/// <para>
/// <b>It never changes eligibility.</b> Confidence is independent of
/// <see cref="ExperienceRecord.CompletionScore"/> and of <see cref="ExperienceRecord.Status"/>: no
/// number here can make an ineligible record eligible. A contradiction moves a
/// <see cref="ExperienceStatus.Validated"/> or <see cref="ExperienceStatus.Reinforced"/> record to
/// <see cref="ExperienceStatus.Contested"/>, and that status change -- not the score -- is what takes
/// it out of reuse.
/// </para>
/// <para>
/// <b>Duplicates are recorded, not counted.</b> The first submission for an independence key is the
/// one that moves the counters. A later submission under a new
/// <see cref="EvidenceId"/> with the same key is still stored, for audit, with
/// <see cref="Counted"/> <see langword="false"/> -- its prior and new values are equal, because
/// nothing moved. Which independence key applies is decided by <see cref="Source"/>; see
/// <see cref="ConfidenceEvidenceSource"/>.
/// </para>
/// </remarks>
/// <param name="EvidenceId">Unique identifier for this submission, and the idempotency key a store deduplicates it on. Must not be <see cref="Guid.Empty"/>.</param>
/// <param name="Kind">Whether this evidence supports reuse or contradicts it.</param>
/// <param name="Source">Whether a machine evaluator or a human reviewer observed it.</param>
/// <param name="RunId">
/// The run the reuse was observed in. Not <see cref="ExperienceRecord.SourceRunId"/>, which is the run
/// the record came from. A host trust boundary: nothing in this library can check that the run happened,
/// so a caller that invents one gets a fresh independence key and can drive the score at will. Establish
/// it from your own run bookkeeping, exactly as you establish <see cref="AuthorizationContext"/>, and
/// never pass through an identifier an agent produced.
/// </param>
/// <param name="VerificationRoundId">
/// The verification round the observation came from. Required for
/// <see cref="ConfidenceEvidenceSource.Machine"/>, and <see langword="null"/> for a human submission. The
/// same host trust boundary as <paramref name="RunId"/>: nothing here can check that a round was closed.
/// </param>
/// <param name="ReviewerIdentity">The reviewing principal. Required for <see cref="ConfidenceEvidenceSource.Human"/>, and <see langword="null"/> for a machine submission. Always the host's <see cref="AuthorizationContext.PrincipalId"/>, never agent input.</param>
/// <param name="RuleVersion">The version of the confidence rule that produced <paramref name="NewReuseConfidence"/>, so a later rule change stays auditable against updates computed under an earlier one.</param>
/// <param name="PriorReuseConfidence">The record's <see cref="ExperienceRecord.ReuseConfidence"/> as Core read it.</param>
/// <param name="NewReuseConfidence">The confidence this update writes. Equal to <paramref name="PriorReuseConfidence"/> when the submission was a duplicate.</param>
/// <param name="PriorSupportingValidations">The record's <see cref="ExperienceRecord.SupportingValidations"/> as Core read it.</param>
/// <param name="NewSupportingValidations">The supporting count this update writes.</param>
/// <param name="PriorContradictions">The record's <see cref="ExperienceRecord.Contradictions"/> as Core read it.</param>
/// <param name="NewContradictions">The contradiction count this update writes.</param>
/// <param name="Detail">Optional sanitized, human-readable detail. Never private reasoning.</param>
public sealed record ConfidenceUpdate(
Guid EvidenceId,
ConfidenceEvidenceKind Kind,
ConfidenceEvidenceSource Source,
Guid RunId,
Guid? VerificationRoundId,
string? ReviewerIdentity,
string RuleVersion,
double PriorReuseConfidence,
double NewReuseConfidence,
int PriorSupportingValidations,
int NewSupportingValidations,
int PriorContradictions,
int NewContradictions,
string? Detail = null)
{
/// <summary>
/// Whether this submission actually moved a counter. It is read off the stored numbers rather than
/// carried as a flag of its own, so a row can never claim it counted while its prior and new values
/// say otherwise.
/// </summary>
public bool Counted =>
NewSupportingValidations != PriorSupportingValidations || NewContradictions != PriorContradictions;

/// <summary>
/// The same submission with nothing moved: what a store writes when the independence key was
/// already taken. Declining to apply an increment is not deriving a score -- every number in the
/// result is one Core already read from the record.
/// </summary>
/// <returns>A copy whose new values equal its prior values.</returns>
public ConfidenceUpdate AsRecordedOnly() => this with
{
NewReuseConfidence = PriorReuseConfidence,
NewSupportingValidations = PriorSupportingValidations,
NewContradictions = PriorContradictions,
};
}
110 changes: 110 additions & 0 deletions src/AgentExperience.Abstractions/ExperienceCandidateSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
namespace AgentExperience.Abstractions;

/// <summary>
/// Port for finding Experience Records that could apply to a task, matched on task text. It is a
/// read-only search seam kept deliberately separate from <see cref="IExperienceRecordStore"/>: the
/// store persists and reads canonical records by identity or scope, while this port answers "which
/// stored records look relevant to this text?" and nothing else.
/// </summary>
/// <remarks>
/// <para>
/// The same trust boundary applies as to <see cref="IExperienceRecordStore"/>: every call takes a
/// host-established <see cref="AuthorizationContext"/>, a request scope outside it is
/// <see cref="ExperienceStoreOutcome.Denied"/> before any storage access, and scope matching is exact
/// (ordinal, case-sensitive, <see langword="null"/> matches only <see langword="null"/>). Expected
/// conditions return typed results; infrastructure failures throw
/// <see cref="ExperienceStoreException"/>; caller cancellation surfaces as an unwrapped
/// <see cref="OperationCanceledException"/>.
/// </para>
/// <para>
/// An implementation decides <em>nothing</em> about eligibility beyond what the query asks for: it
/// applies the scope, the requested statuses, and the minimum confidence, matches the text, and
/// returns each match with a normalized relevance. Which statuses are eligible, whether a record has
/// expired, whether its environment is compatible, and how candidates are ranked are all Core's
/// decisions, made over what this port returns.
/// </para>
/// </remarks>
public interface IExperienceCandidateSource
{
/// <summary>
/// Finds records within exactly <see cref="ExperienceCandidateQuery.Scope"/> whose indexed task
/// text matches <see cref="ExperienceCandidateQuery.TaskText"/>, whose
/// <see cref="ExperienceRecord.Status"/> is one of
/// <see cref="ExperienceCandidateQuery.EligibleStatuses"/>, and whose
/// <see cref="ExperienceRecord.ReuseConfidence"/> is at least
/// <see cref="ExperienceCandidateQuery.MinimumConfidence"/>. At most
/// <see cref="ExperienceCandidateQuery.Limit"/> records are returned, the strongest text matches
/// first.
/// </summary>
/// <param name="authorization">What the host has established the caller may do.</param>
/// <param name="query">The scoped search. Never treated as authority.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns><see cref="ExperienceStoreOutcome.Found"/> (possibly with no candidates), <see cref="ExperienceStoreOutcome.Invalid"/>, or <see cref="ExperienceStoreOutcome.Denied"/>.</returns>
Task<ExperienceCandidateSearchResult> SearchAsync(
AuthorizationContext authorization,
ExperienceCandidateQuery query,
CancellationToken cancellationToken);
}

/// <summary>
/// A scoped, text-matched search for reusable Experience Records.
/// </summary>
/// <param name="Scope">The exact scope to search within. Never treated as authority.</param>
/// <param name="TaskText">The task text to match against. Must be non-blank and at most <see cref="MaxTaskTextLength"/> characters.</param>
/// <param name="EligibleStatuses">The statuses a record must be in to be returned. Must be non-empty and contain only defined values; the caller decides which statuses are eligible.</param>
/// <param name="MinimumConfidence">The smallest <see cref="ExperienceRecord.ReuseConfidence"/> a record may have and still be returned, in [0, 1].</param>
/// <param name="Limit">Maximum number of candidates to return, from <see cref="MinLimit"/> to <see cref="MaxLimit"/>. Defaults to <see cref="DefaultLimit"/>.</param>
public sealed record ExperienceCandidateQuery(
Scope Scope,
string TaskText,
IReadOnlyList<ExperienceStatus> EligibleStatuses,
double MinimumConfidence,
int Limit = ExperienceCandidateQuery.DefaultLimit)
{
/// <summary>
/// The longest permitted <see cref="TaskText"/>. A task description is a sentence or a paragraph;
/// bounding it here keeps an accidental multi-megabyte payload a typed
/// <see cref="ExperienceStoreOutcome.Invalid"/> rather than something the text-search parser chokes
/// on deep inside the database.
/// </summary>
public const int MaxTaskTextLength = 4096;

/// <summary>The smallest permitted <see cref="Limit"/>.</summary>
public const int MinLimit = 1;

/// <summary>The largest permitted <see cref="Limit"/>.</summary>
public const int MaxLimit = 200;

/// <summary>The <see cref="Limit"/> used when none is specified.</summary>
public const int DefaultLimit = 50;
}

/// <summary>
/// One record a search matched, with how strongly its indexed text matched the query.
/// </summary>
/// <param name="Record">The matching record, read back in full.</param>
/// <param name="Relevance">
/// How strongly the record's indexed text matched, normalized to [0, 1] by the implementation, where
/// 0 is no measurable match and 1 is the strongest the implementation can report. Comparable only
/// between candidates from the same search.
/// </param>
/// <param name="SharedByGrant">
/// <see langword="true"/> when this record does not belong to the requested scope and was matched
/// only because an active <see cref="ExperienceGrant"/> permits that scope to read it. Only the
/// implementation that applied the scope predicate knows this, so only it may set it: a caller must
/// never infer sharing from comparing scopes, and a consumer must treat an unset flag as "this record
/// is the requester's own". It exists so a consumer can keep the strict scope check it would
/// otherwise have to weaken, and so borrowed experience can be labelled as such.
/// </param>
public sealed record ExperienceCandidate(ExperienceRecord Record, double Relevance, bool SharedByGrant = false);

/// <summary>
/// The result of <see cref="IExperienceCandidateSource.SearchAsync"/>.
/// </summary>
/// <param name="Outcome">What happened.</param>
/// <param name="Candidates">The matching candidates, strongest match first, when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Found"/>; otherwise empty.</param>
/// <param name="Errors">Every validation error when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Invalid"/>; otherwise empty.</param>
public sealed record ExperienceCandidateSearchResult(
ExperienceStoreOutcome Outcome,
IReadOnlyList<ExperienceCandidate> Candidates,
IReadOnlyList<StoreValidationError> Errors);
Loading
Loading