Skip to content

Order iteration results numerically - #7769

Open
tahakocal wants to merge 2 commits into
dotnet:mainfrom
tahakocal:fix/iteration-name-ordering
Open

tahakocal wants to merge 2 commits into
dotnet:mainfrom
tahakocal:fix/iteration-name-ordering

Conversation

@tahakocal

@tahakocal tahakocal commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Why

IEvaluationResultStore.GetIterationNamesAsync and ReadResultsAsync return iterations in the wrong order when iteration names are numbers, which is what the docs on ScenarioRun.IterationName suggest using ("an integer index that is incremented with each loop iteration"):

written:  1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
returned: 1, 10, 11, 2, 3, 4, 5, 6, 7, 8, 9

DiskBasedResultStore.EnumerateResultFiles sorts with IterationNameComparer, which compares numerically, but it passes the file name ("10.json"). Neither int.TryParse nor double.TryParse matches that, so every comparison fell through to the ordinal branch and the comparer's numeric branches were dead code. The report and everything else that consumes the store sees the wrong order; nothing downstream re-sorts.

AzureStorageResultStore never ordered iteration names at all, so it has the same symptom.

How

  • DiskBasedResultStore sorts by Path.GetFileNameWithoutExtension(f.Name), which is the iteration name it already uses when yielding (GetIterationNamesAsync). This fixes ReadResultsAsync too, since both go through EnumerateResultFiles.
  • AzureStorageResultStore.GetIterationNamesAsync orders with the same comparer, so both stores answer in the same order. Its ReadResultsAsync walks scenarios recursively and is left alone.
  • IterationNameComparer now parses with CultureInfo.InvariantCulture and without AllowThousands. Iteration names are written verbatim into file and blob paths, so "1.5" is a legal name, and with the current-culture parse it compares as 15 wherever . is the group separator (de, tr, es, it, pt-BR ...) - putting "2" before "1.5". The disk store never reached this branch before this change, so the fix would otherwise have introduced the misordering.
  • Equal numbers now fall back to an ordinal comparison, so "01" and "1" have a deterministic order rather than whatever the file system enumerated first.

Test Plan

Microsoft.Extensions.AI.Evaluation.Reporting.Tests: 144 passed, 0 failed, 24 skipped (the Azure ones, which need a configured storage account).

  • ResultStoreTester.IterationNamesAreOrderedNumerically writes iterations "1" through "11" and asserts the order they come back in. Without the fix it fails at the second element with Expected: "2", Actual: "10".
  • IterationNameComparerTests covers the int, double and ordinal branches, the "01"/"1" tie, and pins the culture behaviour by comparing "1.5" against "2" under de-DE.

The Azure store change is not covered by a run here - those tests are skipped without a storage account - but it uses the same comparer as the disk store on a materialized list.

Microsoft Reviewers: Open in CodeFlow

The disk store sorted result files by file name, so the numeric branches
of IterationNameComparer never matched ("10.json" parses as neither an
int nor a double) and iterations came back as 1, 10, 11, 2. Sort by the
iteration name instead, order the names in the Azure store the same way,
and make the comparer culture independent so that a name such as "1.5"
does not parse as 15 where "." is the group separator.
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Evaluation.Reporting.Azure" />

@shyamnamboodiripad shyamnamboodiripad Sep 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have avoided InternalsVisibleTo relationships between the product assemblies as much as possible to avoid unintentional coupling. Instead, we have preferred using linked source files for shared utilities. Is there a reason we cannot do the same here?

Please see this discussion for how we resolved the same issue in another PR: #7718 (comment)

(string path, _) = GetResultPath(executionName, scenarioName);
DataLakeDirectoryClient subClient = client.GetSubDirectoryClient(path);

var iterationNames = new List<string>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor suggestion to match other similar usages above

Suggested change
var iterationNames = new List<string>();
List<string> iterationNames = [];

The Azure store needs the comparer, and making it visible with
InternalsVisibleTo also made every other internal type of the Reporting
assembly visible, which collided with the converter sources that project
already link-compiles and broke the build with CS0436.

Link the comparer in the same way those converters and PathValidation are
already linked, and disambiguate the unit test with the extern alias the
test project is already set up for.

Order the Azure iteration names with OrderBy rather than List.Sort so that
both stores use a stable sort, and match the surrounding collection
expression style.
@tahakocal

Copy link
Copy Markdown
Contributor Author

Thanks — both suggestions applied, and the first one turned out to be more than a style issue.

InternalsVisibleTo → linked source. It was also what broke CI: the Azure project already link-compiles CamelCaseEnumConverter, EvaluationContextConverter, TimeSpanConverter and PathValidation, so making the Reporting assembly's internals visible to it put a second, imported copy of those types in scope and the build failed with CS0436 in AzureStorageJsonUtilities.cs. Linking IterationNameComparer.cs the same way those files are linked removes both the coupling and the error. The unit test now uses the extern alias Reporting that the test project's ProjectReference is already configured for, exactly like DiskBased/PathValidationTests.cs.

List<string> iterationNames = []; applied as suggested.

One more change while I was in there: the Azure store now orders with OrderBy(..., IterationNameComparer.Default) instead of List.Sort. Mixed numeric and non-numeric iteration names make the comparer intransitive ("9" < "10", "10" < "1a", but "9" > "1a"), and List.Sort is the one path that can throw on an inconsistent comparer. OrderBy is a stable merge sort and tolerates it, and it now matches how DiskBasedResultStore orders. I could not make List.Sort actually throw in testing, so this is precautionary; the ordinal tie-break added in this PR is what keeps the comparer returning 0 only for identical strings.

Two things worth flagging for review rather than hiding in the diff:

  • AzureResultStoreTests.IterationNamesAreOrderedNumerically is gated behind SkipIfNotConfigured(), so the Azure half of the fix has no executing coverage in CI. Only the disk-based test actually runs. I verified the Azure path by inspection: both stores now share the same comparer source, and StripExtension produces the same key as Path.GetFileNameWithoutExtension for <name>.json.
  • ReadResultsAsync is still inconsistent between the two stores — disk goes through the ordered enumeration while Azure yields raw GetPathsAsync(recursive: true) order. I left that out of scope here; happy to follow up if you would like it aligned.

Verified locally: Microsoft.Extensions.AI.Evaluation.Reporting, .Reporting.Azure (all five TFMs including netstandard2.0 and net462) and .Console build clean with zero warnings; Reporting.Tests 144 passed / 0 failed / 24 skipped and Console.Tests 14/14 on net8.0, net9.0 and net10.0.

@tahakocal

Copy link
Copy Markdown
Contributor Author

CI update: Build Ubuntu now passes — the CS0436 conflict in AzureStorageJsonUtilities.cs is gone with the linked-source approach.

Build Windows still fails, but not on the code: the Build Azure DevOps plugin step dies in npm install for src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/azure-devops-report with

npm error code E401
npm error Unable to authenticate, your authentication token seems to be invalid.

followed by Copy-Item … node_modules … PathNotFound and PowerShell exited with code '1'. That looks like the internal npm feed token being unavailable to a fork PR rather than anything this change touches — the same step is not reached on the Ubuntu leg. Let me know if you would like me to rebase or if a maintainer needs to re-run it with credentials.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants