using System.Buffers.Binary; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Content; using Tiku.Application.Learning; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; internal interface IV2PracticeSessionService { Task CreateAsync( LearningActor actor, PracticeSessionCommand command, CancellationToken cancellationToken); Task> LoadSolutionsAsync( IReadOnlyCollection sessionQuestions, CancellationToken cancellationToken); } internal sealed class V2PracticeSessionService(LearningServiceDependencies dependencies) : LearningActivityServiceBase(dependencies), IV2PracticeSessionService { public async Task CreateAsync( LearningActor actor, PracticeSessionCommand command, CancellationToken cancellationToken) { var resourceType = command.ResourceType; var resourceId = command.ResourceId; var now = DateTimeOffset.UtcNow; var resourceManifests = await ( from resource in learningAccessPersistence.ProductManifestResources.AsNoTracking() join version in learningAccessPersistence.ProductAccessManifestVersions.AsNoTracking() on new { resource.TenantId, Id = resource.ProductAccessManifestVersionId } equals new { version.TenantId, version.Id } join manifest in learningAccessPersistence.ProductAccessManifests.AsNoTracking() on new { version.TenantId, Id = version.ProductAccessManifestId } equals new { manifest.TenantId, manifest.Id } join product in learningAccessPersistence.LearningProducts.AsNoTracking() on new { manifest.TenantId, Id = manifest.LearningProductId } equals new { product.TenantId, product.Id } where resource.TenantId == actor.TenantId && resource.ResourceType == resourceType && resource.ResourceId == resourceId && version.Status == AccessManifestStatus.Published && manifest.IsActive && product.IsActive select new { resource.ProductAccessManifestVersionId, product.BusinessLineId }) .ToArrayAsync(cancellationToken); var businessLineIds = resourceManifests.Select(item => item.BusinessLineId).Distinct().ToArray(); if (businessLineIds.Length != 1) throw new LearningAccessException("practice_resource_not_entitled", "The requested resource is unavailable in the current learning context."); var snapshot = await effectiveLearningAccessService.GetSnapshotAsync( actor, businessLineIds[0], cancellationToken); var resourceManifestIds = resourceManifests.Select(item => item.ProductAccessManifestVersionId) .Where(snapshot.ManifestVersionIds.Contains) .Distinct() .ToArray(); var grant = await ResolveV2GrantAsync( actor, resourceType, resourceId, resourceManifestIds, now, cancellationToken); if (grant.ManifestVersionIds.Length == 0) throw new LearningAccessException("practice_resource_not_entitled", "The requested resource is not covered by an active product or class grant."); var releaseLinks = await learningAccessPersistence.ProductManifestReleases.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && grant.ManifestVersionIds.Contains(item.ProductAccessManifestVersionId) && snapshot.ContentReleaseIds.Contains(item.ContentReleaseId)) .ToArrayAsync(cancellationToken); releaseLinks = await RestrictReleaseLinksAsync( actor.TenantId, resourceType, resourceId, releaseLinks, cancellationToken); if (releaseLinks.Length == 0) throw new LearningAccessException("practice_resource_release_missing", "The requested resource has no ready content release in this Cell."); const int limit = 100; var seed = StableSeed(actor.UserId, resourceType, resourceId); var candidates = new List(limit); var ownerGroups = releaseLinks.GroupBy(item => item.ContentOwnerTenantId) .OrderBy(group => group.Key) .ToArray(); var rotatedGroups = Rotate(ownerGroups, seed); foreach (var group in rotatedGroups) { var remaining = limit - candidates.Count; if (remaining == 0) break; var ownerCandidates = await contentCandidateReader.GetCandidatesAsync( new ContentCandidateQuery( group.Key, group.Select(item => item.ContentReleaseId).Distinct().ToArray(), snapshot.AudienceSegmentIds, resourceType == AccessResourceType.CurriculumNode ? resourceId : null, resourceType is AccessResourceType.CollectionRelease or AccessResourceType.BlueprintRelease ? await ResolveCollectionReleaseIdsAsync(group.Key, resourceType, resourceId, cancellationToken) : null, remaining, seed), cancellationToken); candidates.AddRange(ownerCandidates); } var distinctCandidates = candidates.DistinctBy(item => item.ContentReleaseQuestionId).Take(limit).ToArray(); if (distinctCandidates.Length == 0) throw new LearningValidationException("no_practice_questions", "No published questions match the current targets and resource."); var deliveries = await LoadV2DeliveriesAsync(distinctCandidates, cancellationToken); var specification = resourceType == AccessResourceType.BlueprintRelease ? await LoadBlueprintSpecificationAsync(releaseLinks, resourceId, cancellationToken) : null; var mode = NormalizeMode(command.Mode ?? (resourceType == AccessResourceType.BlueprintRelease ? "mock_exam" : "chapter")); var durationMinutes = specification?.DurationMinutes; var totalScore = specification?.TotalScore ?? deliveries.Sum(item => item.Policy.DefaultScore); var sessionExpiry = durationMinutes.HasValue ? now.AddMinutes(Math.Min(240, durationMinutes.Value + 15)) : now.AddHours(2); await using var transaction = await unitOfWork.Database.BeginTransactionAsync(cancellationToken); var consumedQuota = await ReserveV2QuestionQuotaAsync( actor, grant, distinctCandidates.Length, cancellationToken); var session = new PracticeSession { TenantId = actor.TenantId, UserId = actor.UserId, Mode = mode, ResourceType = resourceType, ResourceId = resourceId, QuestionCount = distinctCandidates.Length, DurationMinutes = durationMinutes, TotalScore = totalScore, ExpiresAt = sessionExpiry, AccessMode = grant.ClassAssignmentGrantId.HasValue ? PracticeAccessMode.ClassAssignment : PracticeAccessMode.Package, AccessEntitlementId = grant.StudentEntitlementId, AccessClassAssignmentId = grant.ClassAssignmentGrantId, ConsumedFreeQuota = consumedQuota, AccessGrantVersion = snapshot.GrantVersion, StrongRevocationVersion = snapshot.StrongRevocationVersion, AuthorizationExpiresAt = sessionExpiry, AccessSnapshot = JsonSerializer.SerializeToElement(new { strategy = "effective_access_v2", businessLineId = snapshot.BusinessLineId, primaryProfileVersionId = snapshot.PrimaryProfileVersionId, alternateProfileVersionIds = snapshot.AlternateProfileVersionIds.Order().ToArray(), releaseIds = releaseLinks.Select(item => item.ContentReleaseId).Distinct().Order().ToArray(), audienceSegmentIds = snapshot.AudienceSegmentIds.Order().ToArray(), manifestVersionIds = grant.ManifestVersionIds.Order().ToArray(), grantVersion = snapshot.GrantVersion, contentVersion = snapshot.ContentVersion, strongRevocationVersion = snapshot.StrongRevocationVersion, resourceType, resourceId, seed, requestedCount = limit, grantedCount = distinctCandidates.Length }), Metadata = JsonDefaults.Object() }; learningPersistence.PracticeSessions.Add(session); foreach (var delivery in deliveries.OrderBy(item => Array.FindIndex(distinctCandidates, candidate => candidate.ContentReleaseQuestionId == item.Candidate.ContentReleaseQuestionId))) { if (!QuestionGrader.HasValidAuthoritativeAnswer( delivery.Revision.QuestionType, delivery.Revision.CorrectOptionIndex, delivery.Revision.CorrectOptionIndices, delivery.Revision.AnswerText)) throw new LearningValidationException("practice_question_grading_rule_invalid", $"Question revision '{delivery.Revision.Id}' has no valid authoritative grading rule."); learningPersistence.PracticeSessionQuestions.Add(new PracticeSessionQuestion { TenantId = actor.TenantId, PracticeSessionId = session.Id, ContentReleaseQuestionId = delivery.Candidate.ContentReleaseQuestionId, QuestionOwnerTenantId = delivery.Candidate.QuestionAssetOwnerTenantId, QuestionAssetId = delivery.Candidate.QuestionAssetId, QuestionRevisionId = delivery.Candidate.QuestionRevisionId, QuestionPlacementId = delivery.Candidate.QuestionPlacementId, AssessmentPolicyVersionId = delivery.Candidate.AssessmentPolicyVersionId, Position = Array.FindIndex(distinctCandidates, candidate => candidate.ContentReleaseQuestionId == delivery.Candidate.ContentReleaseQuestionId), Score = delivery.Policy.DefaultScore, QuestionType = delivery.Revision.QuestionType, TypeLabelSnapshot = delivery.Revision.TypeLabel, DifficultySnapshot = delivery.Candidate.Difficulty, TagsSnapshot = JsonDefaults.Array(), GradingRulesSnapshot = BuildV2GradingSnapshot(delivery), SnapshotVersion = 2 }); } learningPersistence.PracticeAccessEvents.Add(new PracticeAccessEvent { TenantId = actor.TenantId, UserId = actor.UserId, PracticeSessionId = session.Id, EventType = PracticeAccessEventType.SessionCreated, AccessMode = grant.ClassAssignmentGrantId.HasValue ? PracticeAccessEventMode.ClassAssignment : PracticeAccessEventMode.Package, RequestedCount = limit, GrantedCount = distinctCandidates.Length, ConsumedFreeQuota = consumedQuota, EntitlementId = grant.StudentEntitlementId, Metadata = session.AccessSnapshot }); await unitOfWork.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return ToItem(session); } private async Task ReserveV2QuestionQuotaAsync( LearningActor actor, V2Grant grant, int questionCount, CancellationToken cancellationToken) { if (questionCount <= 0) return 0; if (grant.TotalQuestionLimit.HasValue && grant.StudentEntitlementId.HasValue) { var updated = await learningAccessPersistence.StudentEntitlementsV2 .Where(item => item.TenantId == actor.TenantId && item.Id == grant.StudentEntitlementId.Value && item.Status == StudentEntitlementStatus.Active && item.UsedQuestionCount + questionCount <= grant.TotalQuestionLimit.Value) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.UsedQuestionCount, item => item.UsedQuestionCount + questionCount) .SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), cancellationToken); if (updated != 1) throw new LearningAccessException("practice_total_quota_exhausted", "The product question quota has been exhausted."); } if (!grant.DailyQuestionLimit.HasValue) return grant.TotalQuestionLimit.HasValue ? questionCount : 0; var usageId = Guid.NewGuid(); var scopeType = "product_manifest"; var emptyMetadata = "{}"; var reserved = await learningPersistence.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO practice_daily_usage (id, tenant_id, user_id, usage_date, scope_type, scope_id, free_limit, used_count, metadata, created_at, updated_at) VALUES ({usageId}, {actor.TenantId}, {actor.UserId}, current_date, {scopeType}, {grant.ManifestVersionIds[0]}, {grant.DailyQuestionLimit.Value}, {questionCount}, {emptyMetadata}::jsonb, now(), now()) ON CONFLICT (tenant_id, user_id, usage_date, scope_type, scope_id) DO UPDATE SET used_count = practice_daily_usage.used_count + {questionCount}, updated_at = now() WHERE practice_daily_usage.used_count + {questionCount} <= EXCLUDED.free_limit """, cancellationToken); if (reserved != 1) throw new LearningAccessException("practice_daily_quota_exhausted", "The daily product question quota has been exhausted."); return questionCount; } private async Task ResolveV2GrantAsync( LearningActor actor, AccessResourceType resourceType, Guid resourceId, Guid[] resourceManifestIds, DateTimeOffset now, CancellationToken cancellationToken) { var entitlement = await ( from item in learningAccessPersistence.StudentEntitlementsV2.AsNoTracking() join manifest in learningAccessPersistence.ProductAccessManifestVersions.AsNoTracking() on new { item.TenantId, Id = item.ProductAccessManifestVersionId } equals new { manifest.TenantId, manifest.Id } where item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == StudentEntitlementStatus.Active && item.StartsAt <= now && (item.EndsAt == null || item.EndsAt > now) && resourceManifestIds.Contains(item.ProductAccessManifestVersionId) orderby item.EndsAt select new { item.Id, item.ProductAccessManifestVersionId, manifest.DailyQuestionLimit, manifest.TotalQuestionLimit }) .FirstOrDefaultAsync(cancellationToken); if (entitlement is not null) return new V2Grant( [entitlement.ProductAccessManifestVersionId], entitlement.Id, null, entitlement.DailyQuestionLimit, entitlement.TotalQuestionLimit); var classGrant = await ( from member in learningAccessPersistence.TenantClassMembers.AsNoTracking() join grant in learningAccessPersistence.ClassAssignmentGrants.AsNoTracking() on new { member.TenantId, member.ClassId } equals new { grant.TenantId, grant.ClassId } join manifest in learningAccessPersistence.ProductAccessManifestVersions.AsNoTracking() on new { grant.TenantId, Id = grant.ProductAccessManifestVersionId } equals new { manifest.TenantId, manifest.Id } where member.TenantId == actor.TenantId && member.UserId == actor.UserId && member.MemberType == TenantClassMemberType.Student && member.Status == TenantClassMemberStatus.Active && grant.Status == ClassAssignmentGrantStatus.Active && grant.StartsAt <= now && (grant.EndsAt == null || grant.EndsAt > now) && grant.ResourceType == resourceType && grant.ResourceId == resourceId && resourceManifestIds.Contains(grant.ProductAccessManifestVersionId) select new { grant.Id, grant.ProductAccessManifestVersionId, manifest.DailyQuestionLimit, manifest.TotalQuestionLimit }) .FirstOrDefaultAsync(cancellationToken); return classGrant is null ? new V2Grant([], null, null, null, null) : new V2Grant( [classGrant.ProductAccessManifestVersionId], null, classGrant.Id, classGrant.DailyQuestionLimit, null); } private async Task RestrictReleaseLinksAsync( Guid tenantId, AccessResourceType resourceType, Guid resourceId, ProductManifestRelease[] links, CancellationToken cancellationToken) { if (resourceType == AccessResourceType.ContentRelease) links = links.Where(item => item.ContentReleaseId == resourceId).ToArray(); else if (resourceType == AccessResourceType.CollectionRelease) links = await FilterLinksByCollectionAsync(links, resourceId, cancellationToken); else if (resourceType == AccessResourceType.BlueprintRelease) links = await FilterLinksByBlueprintAsync(links, resourceId, cancellationToken); var packageLinks = links.Where(item => item.PlatformContentPackageVersionId.HasValue).ToArray(); if (packageLinks.Length == 0) return links; var cellId = await learningAccessPersistence.Tenants.AsNoTracking() .Where(item => item.Id == tenantId) .Select(item => item.CellId) .SingleAsync(cancellationToken); var packageIds = packageLinks.Select(item => item.PlatformContentPackageVersionId!.Value).ToArray(); var readyPackageIds = await questionBankPersistence.PlatformContentPackageCellProjections.AsNoTracking() .Where(item => item.CellId == cellId && packageIds.Contains(item.PlatformContentPackageVersionId) && item.Status == CellProjectionStatus.Ready) .Select(item => item.PlatformContentPackageVersionId) .ToArrayAsync(cancellationToken); return links.Where(item => !item.PlatformContentPackageVersionId.HasValue || readyPackageIds.Contains(item.PlatformContentPackageVersionId.Value)) .ToArray(); } private async Task FilterLinksByCollectionAsync( ProductManifestRelease[] links, Guid collectionReleaseId, CancellationToken cancellationToken) { var releaseIds = links.Select(item => item.ContentReleaseId).Distinct().ToArray(); var matches = new List<(Guid TenantId, Guid ContentReleaseId)>(); foreach (var ownerTenantId in links.Select(item => item.ContentOwnerTenantId).Distinct()) matches.AddRange(await ReadForContentOwnerAsync( ownerTenantId, "Resolve an authorized V2 collection release", async (persistence, token) => (await persistence.CollectionReleases.AsNoTracking() .Where(item => item.TenantId == ownerTenantId && item.Id == collectionReleaseId && releaseIds.Contains(item.ContentReleaseId) && item.Status == ContentReleaseStatus.Published) .Select(item => new { item.TenantId, item.ContentReleaseId }) .ToArrayAsync(token)) .Select(item => (item.TenantId, item.ContentReleaseId)).ToArray(), cancellationToken)); return links.Where(link => matches.Any(match => match.TenantId == link.ContentOwnerTenantId && match.ContentReleaseId == link.ContentReleaseId)).ToArray(); } private async Task FilterLinksByBlueprintAsync( ProductManifestRelease[] links, Guid blueprintReleaseId, CancellationToken cancellationToken) { var releaseIds = links.Select(item => item.ContentReleaseId).Distinct().ToArray(); var matches = new List<(Guid TenantId, Guid ContentReleaseId)>(); foreach (var ownerTenantId in links.Select(item => item.ContentOwnerTenantId).Distinct()) matches.AddRange(await ReadForContentOwnerAsync( ownerTenantId, "Resolve an authorized V2 blueprint release", async (persistence, token) => (await persistence.BlueprintReleases.AsNoTracking() .Where(item => item.TenantId == ownerTenantId && item.Id == blueprintReleaseId && releaseIds.Contains(item.ContentReleaseId) && item.Status == ContentReleaseStatus.Published) .Select(item => new { item.TenantId, item.ContentReleaseId }) .ToArrayAsync(token)) .Select(item => (item.TenantId, item.ContentReleaseId)).ToArray(), cancellationToken)); return links.Where(link => matches.Any(match => match.TenantId == link.ContentOwnerTenantId && match.ContentReleaseId == link.ContentReleaseId)).ToArray(); } private async Task?> ResolveCollectionReleaseIdsAsync( Guid ownerTenantId, AccessResourceType resourceType, Guid resourceId, CancellationToken cancellationToken) { if (resourceType == AccessResourceType.CollectionRelease) return [resourceId]; if (resourceType != AccessResourceType.BlueprintRelease) return null; var collectionId = (await ReadForContentOwnerAsync( ownerTenantId, "Resolve the collection pinned by an authorized V2 blueprint", (persistence, token) => persistence.BlueprintReleases.AsNoTracking() .Where(item => item.TenantId == ownerTenantId && item.Id == resourceId) .Select(item => item.CollectionReleaseId) .ToArrayAsync(token), cancellationToken)).SingleOrDefault(); return collectionId.HasValue ? [collectionId.Value] : null; } private async Task LoadV2DeliveriesAsync( IReadOnlyCollection candidates, CancellationToken cancellationToken) { var revisions = new List(); foreach (var ownerGroup in candidates.GroupBy(item => item.QuestionAssetOwnerTenantId)) { var revisionIds = ownerGroup.Select(item => item.QuestionRevisionId).Distinct().ToArray(); revisions.AddRange(await ReadForContentOwnerAsync( ownerGroup.Key, "Load explicitly authorized immutable V2 question revisions", (persistence, token) => persistence.QuestionRevisions.AsNoTracking() .Where(item => item.TenantId == ownerGroup.Key && revisionIds.Contains(item.Id)) .ToArrayAsync(token), cancellationToken)); } var policies = new List(); var objectiveRules = new List(); foreach (var ownerGroup in candidates.GroupBy(item => item.ContentOwnerTenantId)) { var policyIds = ownerGroup.Select(item => item.AssessmentPolicyVersionId).Distinct().ToArray(); policies.AddRange(await ReadForContentOwnerAsync( ownerGroup.Key, "Load explicitly authorized immutable V2 assessment policies", (persistence, token) => persistence.AssessmentPolicyVersions.AsNoTracking() .Where(item => item.TenantId == ownerGroup.Key && policyIds.Contains(item.Id) && item.Status == ContentDefinitionStatus.Published) .ToArrayAsync(token), cancellationToken)); objectiveRules.AddRange(await ReadForContentOwnerAsync( ownerGroup.Key, "Load explicitly authorized immutable V2 objective rules", (persistence, token) => persistence.ObjectiveGradingRules.AsNoTracking() .Where(item => item.TenantId == ownerGroup.Key && policyIds.Contains(item.AssessmentPolicyVersionId)) .ToArrayAsync(token), cancellationToken)); } return candidates.Select(candidate => { var revision = revisions.SingleOrDefault(item => item.TenantId == candidate.QuestionAssetOwnerTenantId && item.Id == candidate.QuestionRevisionId) ?? throw new LearningValidationException( "practice_question_revision_missing", "An immutable question revision is missing."); var policy = policies.SingleOrDefault(item => item.TenantId == candidate.ContentOwnerTenantId && item.Id == candidate.AssessmentPolicyVersionId) ?? throw new LearningValidationException( "practice_assessment_policy_missing", "A published assessment policy is missing."); return new V2Delivery( candidate, revision, policy, objectiveRules.Where(item => item.TenantId == candidate.ContentOwnerTenantId && item.AssessmentPolicyVersionId == policy.Id) .OrderBy(item => item.Id) .ToArray()); }).ToArray(); } private Task ReadForContentOwnerAsync( Guid ownerTenantId, string reason, Func> read, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( ownerTenantId, SystemScopeCallerType.PublicQuestionBank, nameof(PracticeSessionService), reason, Guid.NewGuid().ToString("N")), (provider, token) => read(provider.GetRequiredService(), token), cancellationToken); } private async Task LoadBlueprintSpecificationAsync( IReadOnlyCollection links, Guid blueprintReleaseId, CancellationToken cancellationToken) { var owners = links.Select(item => item.ContentOwnerTenantId).Distinct().ToArray(); return await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( null, SystemScopeCallerType.PublicQuestionBank, nameof(PracticeSessionService), "Load an explicitly authorized published blueprint specification", Guid.NewGuid().ToString("N"), true), async (provider, token) => { var persistence = provider.GetRequiredService(); return await ( from blueprint in persistence.BlueprintReleases.AsNoTracking() join specification in persistence.ExamPaperSpecificationVersions.AsNoTracking() on new { blueprint.TenantId, Id = blueprint.ExamPaperSpecificationVersionId } equals new { specification.TenantId, specification.Id } where owners.Contains(blueprint.TenantId) && blueprint.Id == blueprintReleaseId && blueprint.Status == ContentReleaseStatus.Published && specification.Status == ContentDefinitionStatus.Published select specification) .SingleOrDefaultAsync(token); }, cancellationToken); } public async Task> LoadSolutionsAsync( IReadOnlyCollection sessionQuestions, CancellationToken cancellationToken) { if (sessionQuestions.Count == 0) return []; var revisions = new List(); foreach (var ownerGroup in sessionQuestions.GroupBy(item => item.QuestionOwnerTenantId)) { var revisionIds = ownerGroup.Select(item => item.QuestionRevisionId).Distinct().ToArray(); revisions.AddRange(await ReadForContentOwnerAsync( ownerGroup.Key, "Reveal explicitly locked V2 question solutions", (persistence, token) => persistence.QuestionRevisions.AsNoTracking() .Where(item => item.TenantId == ownerGroup.Key && revisionIds.Contains(item.Id)) .ToArrayAsync(token), cancellationToken)); } return sessionQuestions.ToDictionary( item => item.Id, item => { var revision = revisions.Single(version => version.TenantId == item.QuestionOwnerTenantId && version.Id == item.QuestionRevisionId); return new QuestionSolutionItem( revision.CorrectOptionIndex, revision.CorrectOptionIndices, revision.AnswerText, revision.Explanation); }); } private static JsonElement BuildV2GradingSnapshot(V2Delivery delivery) { return JsonSerializer.SerializeToElement(new { version = 2, assessmentPolicyVersionId = delivery.Policy.Id, gradingMode = delivery.Policy.GradingMode, roundingScale = delivery.Policy.RoundingScale, correctOptionIndex = delivery.Revision.CorrectOptionIndex, correctOptionIndices = delivery.Revision.CorrectOptionIndices, answerText = delivery.Revision.AnswerText, objectiveRules = delivery.ObjectiveRules.Select(item => new { item.RuleType, item.Configuration }).ToArray() }); } private static int StableSeed(Guid userId, AccessResourceType resourceType, Guid resourceId) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{userId:N}:{resourceType}:{resourceId:N}")); return BinaryPrimitives.ReadInt32LittleEndian(hash); } private static T[] Rotate(T[] values, int seed) { if (values.Length <= 1) return values; var start = (int)((uint)seed % (uint)values.Length); return values.Skip(start).Concat(values.Take(start)).ToArray(); } private sealed record V2Grant( Guid[] ManifestVersionIds, Guid? StudentEntitlementId, Guid? ClassAssignmentGrantId, int? DailyQuestionLimit, int? TotalQuestionLimit); private sealed record V2Delivery( ContentCandidateItem Candidate, QuestionRevision Revision, AssessmentPolicyVersion Policy, ObjectiveGradingRule[] ObjectiveRules); }