Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateJobRuns1772000000000 implements MigrationInterface {
name = 'CreateJobRuns1772000000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "job_runs_status_enum" AS ENUM ('running', 'completed', 'failed', 'skipped')`,
);

await queryRunner.query(`
CREATE TABLE "job_runs" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"jobName" character varying(100) NOT NULL,
"status" "job_runs_status_enum" NOT NULL DEFAULT 'running',
"triggeredBy" character varying(50) NOT NULL DEFAULT 'scheduled',
"result" jsonb,
"errorMessage" text,
"startedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"finishedAt" TIMESTAMP WITH TIME ZONE,
"durationMs" integer,
CONSTRAINT "PK_job_runs" PRIMARY KEY ("id")
)
`);

await queryRunner.query(
`CREATE INDEX "IDX_job_runs_name_started" ON "job_runs" ("jobName", "startedAt" DESC)`,
);

await queryRunner.query(
`CREATE INDEX "IDX_job_runs_status" ON "job_runs" ("status")`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_job_runs_status"`);
await queryRunner.query(`DROP INDEX "IDX_job_runs_name_started"`);
await queryRunner.query(`DROP TABLE "job_runs"`);
await queryRunner.query(`DROP TYPE "job_runs_status_enum"`);
}
}
28 changes: 27 additions & 1 deletion apps/backend/src/grants/dto/grants.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,34 @@ export interface ProjectQfDto {
estimatedMatch: string;
}

export interface ProjectAllocationDto extends ProjectQfDto {
contributionPercentage: string;
qfPercentage: string;
allocationPercentage: string;
}

export interface RoundParticipationMetricsDto {
totalContributors: number;
totalContributionAmount: string;
totalContributionRecords: number;
totalProjectsWithContributions: number;
averageContributionPerContributor: string;
averageContributionPerProject: string;
}

export interface ContributionRecordDto {
projectId: number;
contributorPublicKey: string;
amount: string;
}

export interface RoundSummaryDto {
round: RoundDto;
poolBalance: string;
projects: ProjectQfDto[];
participationMetrics: RoundParticipationMetricsDto;
projects: ProjectAllocationDto[];
}

export interface RoundExportDto extends RoundSummaryDto {
contributions: ContributionRecordDto[];
}
5 changes: 5 additions & 0 deletions apps/backend/src/grants/grants.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export class GrantsController {
return this.grantsService.getRoundSummary(id);
}

@Get('rounds/:id/export')
getRoundExport(@Param('id', ParseIntPipe) id: number) {
return this.grantsService.getRoundExport(id);
}

@Post('rounds')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
Expand Down
50 changes: 50 additions & 0 deletions apps/backend/src/grants/grants.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,54 @@ describe('GrantsService', () => {
);
expect(total).toBe(BigInt(summary.poolBalance));
});

it('adds participation metrics and provides an exportable round dataset', () => {
const round = service.createRound({
name: 'R',
tokenAddress: 'GT',
startTime: past,
endTime: future,
});
service.fundPool({
roundId: round.id,
funderPublicKey: 'GF',
amount: '1000',
});
service.approveProject({ roundId: round.id, projectId: 1 });
service.approveProject({ roundId: round.id, projectId: 2 });

service.recordContribution({
roundId: round.id,
projectId: 1,
contributorPublicKey: 'GA',
amount: '100',
});
service.recordContribution({
roundId: round.id,
projectId: 1,
contributorPublicKey: 'GB',
amount: '100',
});
service.recordContribution({
roundId: round.id,
projectId: 2,
contributorPublicKey: 'GC',
amount: '200',
});

const summary = service.getRoundSummary(round.id);
expect(summary.participationMetrics.totalContributors).toBe(3);
expect(summary.participationMetrics.totalContributionAmount).toBe('400');
expect(summary.participationMetrics.totalContributionRecords).toBe(3);
expect(summary.participationMetrics.totalProjectsWithContributions).toBe(2);
expect(summary.projects.every((project) => project.contributionPercentage !== undefined)).toBe(true);

const exportData = service.getRoundExport(round.id);
expect(exportData.contributions).toHaveLength(3);
expect(exportData.contributions[0]).toMatchObject({
projectId: 1,
contributorPublicKey: 'GA',
amount: '100',
});
});
});
149 changes: 130 additions & 19 deletions apps/backend/src/grants/grants.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import {
import { ConfigService } from '@nestjs/config';
import {
RoundDto,
ProjectQfDto,
// ProjectQfDto,
ProjectAllocationDto,
RoundParticipationMetricsDto,
ContributionRecordDto,
RoundSummaryDto,
RoundExportDto,
CreateRoundDto,
FundPoolDto,
ApproveProjectDto,
Expand Down Expand Up @@ -209,59 +213,166 @@ export class GrantsService {
low > 0n ? ((value - low * low) * scale) / (2n * low) : 0n;
return intPart + remainder;
}
private formatPercentage(
part: bigint,
total: bigint,
decimals = 2,
): string {
if (total === 0n) return `0.${'0'.repeat(decimals)}`;
const scale = 10n ** BigInt(decimals);
const value = (part * 100n * scale + total / 2n) / total;
const integer = value / scale;
const fraction = (value % scale).toString().padStart(decimals, '0');
return `${integer}.${fraction}`;
}

getRoundSummary(roundId: number): RoundSummaryDto {
const record = this.getRecord(roundId);

// Compute all QF scores
const scores = new Map<number, bigint>();
let totalQf = 0n;
private computeParticipationMetrics(
record: RoundRecord,
): RoundParticipationMetricsDto {
let totalContributionAmount = 0n;
let totalContributionRecords = 0;
let totalProjectsWithContributions = 0;
const uniqueContributors = new Set<string>();

for (const pid of record.eligibleProjects) {
const contribs =
record.contributions.get(pid) ?? new Map<string, bigint>();
const score = this.computeQfScore(contribs);
scores.set(pid, score);
totalQf += score;
const contribs = record.contributions.get(pid) ?? new Map<string, bigint>();
const projectTotal = [...contribs.values()].reduce(
(a: bigint, b: bigint) => a + b,
0n,
);
if (projectTotal > 0n) {
totalProjectsWithContributions += 1;
}
totalContributionAmount += projectTotal;
totalContributionRecords += contribs.size;
for (const contributor of contribs.keys()) {
uniqueContributors.add(contributor);
}
}

const projects: ProjectQfDto[] = [];
return {
totalContributors: uniqueContributors.size,
totalContributionAmount: totalContributionAmount.toString(),
totalContributionRecords,
totalProjectsWithContributions,
averageContributionPerContributor:
uniqueContributors.size > 0
? (totalContributionAmount / BigInt(uniqueContributors.size)).toString()
: '0',
averageContributionPerProject:
totalProjectsWithContributions > 0
? (totalContributionAmount /
BigInt(totalProjectsWithContributions)).toString()
: '0',
};
}

private buildProjectAllocations(
record: RoundRecord,
scores: Map<number, bigint>,
totalQf: bigint,
totalContributionAmount: bigint,
): ProjectAllocationDto[] {
const allocations: ProjectAllocationDto[] = [];

for (const pid of record.eligibleProjects) {
const contribs =
record.contributions.get(pid) ?? new Map<string, bigint>();
const contribs = record.contributions.get(pid) ?? new Map<string, bigint>();
const score = scores.get(pid) ?? 0n;
const totalContribs = [...contribs.values()].reduce(
(a: bigint, b: bigint) => a + b,
0n,
);

const estimatedMatch =
totalQf > 0n && record.totalPool > 0n
? (record.totalPool * score) / totalQf
: 0n;

projects.push({
allocations.push({
projectId: pid,
qfScore: score.toString(),
totalContributions: totalContribs.toString(),
contributorCount: contribs.size,
estimatedMatch: estimatedMatch.toString(),
contributionPercentage: this.formatPercentage(
totalContribs,
totalContributionAmount,
),
qfPercentage: this.formatPercentage(score, totalQf),
allocationPercentage: this.formatPercentage(
estimatedMatch,
record.totalPool,
),
});
}

// Sort by estimated match descending
projects.sort((a, b) =>
allocations.sort((a, b) =>
Number(BigInt(b.estimatedMatch) - BigInt(a.estimatedMatch)),
);
return allocations;
}

private buildContributionRecords(
record: RoundRecord,
): ContributionRecordDto[] {
const contributions: ContributionRecordDto[] = [];

for (const pid of record.eligibleProjects) {
const contribs = record.contributions.get(pid) ?? new Map<string, bigint>();
for (const [contributorPublicKey, amount] of contribs.entries()) {
contributions.push({
projectId: pid,
contributorPublicKey,
amount: amount.toString(),
});
}
}

contributions.sort((a, b) =>
a.projectId - b.projectId || a.contributorPublicKey.localeCompare(b.contributorPublicKey),
);
return contributions;
}

getRoundSummary(roundId: number): RoundSummaryDto {
const record = this.getRecord(roundId);

const scores = new Map<number, bigint>();
let totalQf = 0n;

for (const pid of record.eligibleProjects) {
const contribs =
record.contributions.get(pid) ?? new Map<string, bigint>();
const score = this.computeQfScore(contribs);
scores.set(pid, score);
totalQf += score;
}

const participationMetrics = this.computeParticipationMetrics(record);
const totalContributionAmount = BigInt(participationMetrics.totalContributionAmount);
const projects = this.buildProjectAllocations(
record,
scores,
totalQf,
totalContributionAmount,
);

return {
round: this.toRoundDto(record),
poolBalance: record.totalPool.toString(),
participationMetrics,
projects,
};
}

getRoundExport(roundId: number): RoundExportDto {
const summary = this.getRoundSummary(roundId);
const record = this.getRecord(roundId);
return {
...summary,
contributions: this.buildContributionRecords(record),
};
}

distribute(dto: DistributeDto): {
totalDistributed: string;
allocations: { projectId: number; owner: string; amount: string }[];
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/model-retraining/model-retraining.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
import { ModelRetrainingService } from './model-retraining.service';
import { ModelRetrainingScheduler } from './model-retraining.scheduler';
import { ModelRetrainingController } from './model-retraining.controller';
import { SchedulerModule } from '../scheduler/scheduler.module';

@Module({
imports: [
Expand All @@ -14,6 +15,7 @@ import { ModelRetrainingController } from './model-retraining.controller';
}),
}),
ConfigModule,
SchedulerModule,
],
providers: [ModelRetrainingService, ModelRetrainingScheduler],
controllers: [ModelRetrainingController],
Expand Down
Loading